diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 23935ef2ad..93feec2b95 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -27,14 +27,14 @@ en: d5d0d6a57a4fa581 zh: 265973b157f957d9 /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller: - en: e338ac27490a380f - zh: 12600011ec742e5b + en: f432480a52414d56 + zh: 15a3f175b69cdb53 /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/activation-policy: - en: 534dcc62a26d305e - zh: 03d64abc0a46bbec + en: 03e24cdee8519a92 + zh: 82dc02eb26d7d7f4 /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/session-journal: - en: bb710f8cf35f2028 - zh: aae2cfb8cd848069 + en: 4de2965717900b96 + zh: 1d129003824fa6bd /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/session-live-control: en: a11d627cd68650c3 zh: 9aff565b9f712853 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index de05a0e62b..ee43b551eb 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -139,11 +139,11 @@ If a page request is canceled with its physical carrier generation, the journal `packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace. -It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through `session/modelCatalog` because it is not Session-specific. +It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, `events`, the deprecated `page`, `follow`, `control`, plus `observeEvent`, `projections`, `observeProjections`, `state`, `observeState`, and `wait`. The Host-generation model catalog is exposed separately through `session/modelCatalog` because it is not Session-specific. The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner. -Other Host Remote namespaces reuse the same identity rules through `ctx.sessionController.inspect()` or `resolveAgent()`; they do not retain a second Session resolver. +Other Host Remote namespaces reuse the same resume-capable identity rules through `ctx.sessionController.resolveAgent()`; they do not retain a second Session resolver. #### Activation policy @@ -157,7 +157,8 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca | `session.page(address)` | attached Session or persistence log | Never resumes an Agent | | `session.follow(address)` | one live or prepared observation carrying the opening page and projections | Publishes the snapshot first, then promotes an ordinary cold Session once in the background | | `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent | -| `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent | +| Fork source read | authorized durable Session data | A read does not resume an Agent | +| `session.attachment` | attachment store under the [image access rule](2026-10-23-store-scoped-image-reads.md) | A read does not resume an Agent | | `session.updateQueue` | live Agent or ordinary persisted Session | Resumes an ordinary cold Session before mutating its Inbox | | `session.cancel` | only the current live Agent | Does not resume vanished state | | `models`, `selectModel`, `rename`, `prompt` | command resolves the target Session | Resumes only when the method explicitly permits it | @@ -173,9 +174,9 @@ Reading titles, lists, and projections does not require an Agent. An observation #### Session journal -`session.page` returns a history window clipped on message boundaries with contiguous internal sequence numbers. Every request must carry an explicit `throughSeq`; this value comes from the corresponding `session.follow` generation's opening cursor and fixes the read at the same log cut. A tail page without `beforeSeq` must end exactly at `throughSeq`, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages. +`session.events` returns a history window clipped on message boundaries with contiguous internal sequence numbers. `throughSeq` is optional: when omitted, the read selects the observed log end and returns it as the page's inclusive `cursor`; a supplied value fixes the read at that cut. A page without `beforeSeq` ends exactly at the selected cut, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages. The deprecated `session.page` entry returns the same type. -The tail page also carries a projection baseline no later than `throughSeq`; older pages carry only historical entries. The Client merges pages and subsequent live control updates by projection watermark. +A page carries only its records, `hasMore`, and inclusive `cursor`; a projection baseline comes from a follow opening snapshot or the control stream, never from a page. The Client merges pages and subsequent live control updates by projection watermark. Ordinary Sessions and direct subagents use one `SessionAddress` protocol. A direct-subagent address carries parent Session, child Session, and mode; a cold Host read verifies durable ownership and descriptor rather than authorizing access from the child id alone. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 004da80fee..9df5c55701 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -139,11 +139,11 @@ repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼 `packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。 -它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 `session/modelCatalog` 公开,因为它不属于特定 Session。 +它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、`events`、已弃用的 `page`、`follow`、`control`,以及 `observeEvent`、`projections`、`observeProjections`、`state`、`observeState` 与 `wait`。Host generation 的 model catalog 通过独立的 `session/modelCatalog` 公开,因为它不属于特定 Session。 包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。 -其他 Host Remote namespace 通过 `ctx.sessionController.inspect()` 或 `resolveAgent()` 复用同一身份规则,不保留第二份 Session resolver。 +其他 Host Remote namespace 通过 `ctx.sessionController.resolveAgent()` 复用同一套允许恢复 Session 的身份规则,不保留第二份 Session resolver。 #### 激活策略 @@ -157,7 +157,8 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 | `session.page(address)` | attached Session 或 persistence 日志 | 永不恢复 Agent | | `session.follow(address)` | 一份携带 opening page 与 projection 的 live 或 prepared observation | 先发布 snapshot,再在后台把普通冷 Session 提升一次 | | `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent | -| `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent | +| Fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent | +| `session.attachment` | 按[图片访问规则](2026-10-23-store-scoped-image-reads.zh.md)读取附件存储 | 读取不恢复 Agent | | `session.updateQueue` | live Agent 或普通持久 Session | 修改 Inbox 前恢复普通冷 Session | | `session.cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent | | `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 | @@ -173,9 +174,9 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 #### Session 日志 -`session.page` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 `throughSeq`;该值来自对应 `session.follow` generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 `beforeSeq` 的 tail page 必须精确结束于 `throughSeq`,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。 +`session.events` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。`throughSeq` 可省略:省略时选取观察到的日志末尾,并把该切点作为页面的 inclusive `cursor` 返回;显式提供时把本次读取固定在该切点。无 `beforeSeq` 的 page 精确结束于所选切点,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。已弃用的 `session.page` 入口返回同一类型。 -tail page 同时携带不晚于 `throughSeq` 的 projection baseline;旧页只携带历史 entries。Client 以 projection watermark 合并 page 与后续 live control 更新。 +page 只携带记录、`hasMore` 和 inclusive `cursor`;projection baseline 来自 follow 的开场快照或 control 流,不来自 page。Client 以 projection watermark 合并 page 与后续 live control 更新。 普通 Session 与 direct subagent 使用同一个 `SessionAddress` 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index e70e858963..86b5f42ac4 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml @@ -51,8 +51,8 @@ en: 51f99cfca9c4c808 zh: ce95bab241d306f7 /agent-note-client-derived-presentation-from-raw-session-tool-events/host-design: - en: 16fd2b3e545ede21 - zh: 4d28f52132495441 + en: d5e5d1376dc42558 + zh: c2f302b2a5d1de39 /agent-note-client-derived-presentation-from-raw-session-tool-events/host-design/performance-constraints: en: 102b14e3eb9367ff zh: d391099dbc41db24 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 8a0c4ea6fc..8c32436c6a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -195,9 +195,9 @@ The Client narrows `meta` locally at runtime. Renaming `presentationMeta` to mor ## Host Design -After obtaining source events, `SessionHistoryController.page()` performs only pagination and the existing projection-baseline calculation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set. +After obtaining source events, `HistoryController.history()` performs only pagination and requests projections only for a subagent address that needs descriptor validation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set. -`SessionHistoryController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events. +`WebController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events. The controller has no `presenterScopeFor()`, `viewFor()`, `backscanArgs()`, `parseToolCall()`, or `jsonView()` path. Page state contains no presenter scope or argument resolver; follow state contains no `openCalls`, `fallbackEvents`, or presentation argument resolver. Each page/follow event is wrapped only as `{event}` while addressing, ownership, cursor, sequence, and projection logic remains intact. diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 2dd4ad8bc9..6ba3e040bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -195,9 +195,9 @@ Client 对 `meta` 做局部运行时收窄。是否把 `presentationMeta` 改名 ## Host 端设计 -`SessionHistoryController.page()` 在取得 source events 后只执行分页与现有 projection baseline 计算。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。 +`HistoryController.history()` 在取得 source events 后只执行分页,并且只有需要校验 descriptor 的 subagent 地址才请求投影。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。 -`SessionHistoryController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。 +`WebController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。 Controller 不存在 `presenterScopeFor()`、`viewFor()`、`backscanArgs()`、`parseToolCall()` 或 `jsonView()` 路径。page state 不含 presenter scope 或参数 resolver;follow state 不含 `openCalls`、`fallbackEvents` 或 presentation 参数 resolver。每个 page/follow event 只包装成 `{event}`,地址、ownership、cursor、seq 与 projection 逻辑保持完整。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index bd2626e850..c67dff0722 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -15,17 +15,17 @@ en: 480284ae1a6ce210 zh: d42c72ada9f019f8 /agent-note-session-observations-and-projection-owned-client-state/decision/observation-is-the-point-read-unit: - en: fac92f8547482a49 - zh: 60240c47fbe4611f + en: 283136ae36512767 + zh: e342b446c77ae300 /agent-note-session-observations-and-projection-owned-client-state/decision/source-resolution-and-lifetime: - en: e550ae9892474276 - zh: fb6a2d9e91ebd4d9 + en: bced4b90cb03525d + zh: b0c1ca4bf1064f89 /agent-note-session-observations-and-projection-owned-client-state/decision/projection-execution-boundary: - en: 148de86941b90f41 - zh: c065a8e96e28b9d7 + en: b3b235fa0250868a + zh: 0bb5eedf14cdafee /agent-note-session-observations-and-projection-owned-client-state/decision/read-audiences: - en: 888f62659f4d868d - zh: e74491e8c85046ee + en: e394e9480340f69c + zh: 7eea994ee9fa380d /agent-note-session-observations-and-projection-owned-client-state/decision/replayable-client-facts-are-projection-owned: en: efded09f0cd30a8e zh: 5e16580a39ff3359 @@ -48,11 +48,11 @@ en: 8d699dfc22ab5c5f zh: 8dcd9fc6bd1594db /agent-note-session-observations-and-projection-owned-client-state/verification: - en: df17217023668428 - zh: 88f7da4deffc6bf9 + en: 29f364d1031b7d85 + zh: 2b193ceb52fb0406 /agent-note-session-observations-and-projection-owned-client-state/alternatives-considered: - en: a25138b4298cda16 - zh: 92799a9bd8867d47 + en: 90574351058d5d8c + zh: 6aa514cf47495ed2 /agent-note-session-observations-and-projection-owned-client-state/consequences: en: e1f8bd8fa68de603 zh: 5afcd06f2611bc1e diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 523122c9ae..530aeb36e1 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -24,7 +24,7 @@ The two ownership rules meet at the observation's projection snapshot. Lightweig flowchart LR List["list / search"] --> Corpus["SessionQuery corpus"] Follow["follow"] --> Observe["observeSession"] - Page["page / attachment / fork"] --> Observe + Page["page / fork"] --> Observe Subagent["subagent list / continuation"] --> Observe Corpus --> Cache["projection cache hints"] Cache --> ClientList["Client Session list"] @@ -46,7 +46,7 @@ flowchart LR `SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise the reader's own prepared cache — keyed by `stat().revision` and pinned by observation leases — serves the cold Session, sharing one persistence read (`open(id, 'read')` + `read`) across concurrent observations, including an in-flight cold load. -Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` publish a snapshot and then transfer that exact prepared source to background Agent promotion without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold. +Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` transfer that exact prepared source to background Agent promotion before delivering the snapshot, without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold. ### Source resolution and lifetime @@ -56,7 +56,7 @@ Live preference is checked both before and after a cold borrow. The second check Persistence absence maps to Session-not-found only after no attached Session exists. Durable corruption, source-identity conflict, cancellation, and operational persistence failure remain distinct `SessionQueryError` outcomes so API owners can preserve their own public error vocabulary without duplicating source detection. -The observation owns no mutation authority. Its event array is an immutable prefix, and its prepared Session remains unpublished. Promotion is an explicit ownership transfer performed by the Session Controller after it has emitted the opening snapshot; other readers cannot turn an observation into a live Agent. +The observation owns no mutation authority. Its event array is an immutable prefix, and its prepared Session remains unpublished. Promotion is an explicit ownership transfer the Session Controller starts once the opening is computed, before delivering it; other readers cannot turn an observation into a live Agent. Projection work is deliberately `all | none`. `all` computes every registered projection at the observation's event cursor; `none` leaves projection state untouched. There is no per-key preparation state, `projectionKeys` mode, or cached `viewedState`/`viewedValue` layer. A publisher may filter the completed values for an audience, but the underlying observation is never partly projected. @@ -72,7 +72,7 @@ The registry owns fold state; each domain owns its `init`, `apply`, `view`, sche Corpus listing remains a separate lightweight operation. `listSessions()` returns live-preferred headers without materializing every log. Session list first uses live projection state or durable projection-cache rows. Subagent discovery reads parent-catalog projections through exact observations. Session list may take one complete observation for an individually stored artifact within its configured small-log limit when cached metadata cannot establish whether it is blank; a large or unreadable cache miss remains visible with unknown hints. -`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` is reserved for older-history reads and gap repair. Observation-only reads never activate an Agent; only an ordinary follow may retain its prepared observation and request promotion after the opening snapshot has been delivered. +`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` supports current or fixed-cut history reads, older-history reads, and gap repair. Observation-only reads never activate an Agent; only an activation-enabled ordinary Follow may retain its prepared observation and request promotion once the opening snapshot is computed, before it is delivered. The [snapshot and subscription decision](2026-10-21-session-history-stream-and-snapshot.md) defines the Controller composition and passive subscription semantics. ### Read audiences @@ -82,9 +82,10 @@ Each public operation chooses one query and projection policy. The choice is par |---|---|---|---| | `session.list` | Corpus headers, live state, and cached rows; bounded small-log fallback | Partial hints, or one full small-log observation | Never | | `session.search` | Corpus authorization plus the configured search provider | None for result listing | Never | -| `session.follow` | One exact observation | All, carried in the opening snapshot | Ordinary cold Session only, after snapshot delivery | +| `session.follow` | One exact observation | All, carried in the opening snapshot | Only with `activate: true`, for an ordinary cold Session once the opening is computed, before delivery | | `session.page` | One exact observation | None, except projection-backed subagent authorization | Never | -| Attachment and fork source | One exact observation | None unless authorization requires it | Never for the source | +| Fork source | One exact observation | None unless authorization requires it | Never for the source | +| Attachment | Direct store read under the [image access rule](2026-10-23-store-scoped-image-reads.md) | None | Never | | Subagent list and continuation | Parent-catalog observations for listing; target observations for continuation | All; listing consumes catalog values, continuation consumes identity and inherited values | Never for listing; continuation follows its explicit command semantics | ### Replayable Client facts are projection-owned @@ -171,7 +172,7 @@ These rules apply to new Session-derived Client state even when a direct event s ## Verification -Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion after snapshot delivery. +Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion once the opening is computed. Client tests pin higher-sequence-wins projection storage, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin parent-catalog order, recursive traversal, branch diagnostics, cancellation, fork isolation, and no Agent activation during listing. @@ -179,7 +180,7 @@ Client tests pin higher-sequence-wins projection storage, title updates, model c **Keep source resolution in each consumer.** Rejected because every caller would continue to implement its own live race, persistence error mapping, preparation lifetime, cancellation, and projection cut, allowing both duplicate work and inconsistent results. -**Activate an Agent for every exact read.** Rejected because list, history, attachment, search, and subagent inspection are read operations. Activation loads plugins and changes process state, and it has no natural retirement point for pagination or catalog reads. +**Activate an Agent for every exact read.** Rejected because list, history, search, and subagent inspection are read operations. Activation loads plugins and changes process state, and it has no natural retirement point for pagination or catalog reads. **Prepare only requested projection keys.** Rejected because a partially projected Session creates another lifecycle state that every cache, restore, plugin-registration, and caller path must track. Projection units are pure and few; computing all registered units for an exact observation is simpler than maintaining `O(E*k)` partial state instead of `O(E*P)` complete state. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index 6639047731..62ac9db852 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -24,7 +24,7 @@ Session 精确读取使用可保留的 `SessionObservation`,向 Client 暴露 flowchart LR List["list / search"] --> Corpus["SessionQuery corpus"] Follow["follow"] --> Observe["observeSession"] - Page["page / attachment / fork"] --> Observe + Page["page / fork"] --> Observe Subagent["subagent list / continuation"] --> Observe Corpus --> Cache["projection cache hints"] Cache --> ClientList["Client Session list"] @@ -46,7 +46,7 @@ flowchart LR `SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则由读取方自己的 prepared cache——以 `stat().revision` 为键、由 observation lease 固定——提供冷 Session,让并发 observation 共享同一次持久化读取(`open(id, 'read')` + `read`),包括尚未完成的冷加载。 -每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能够先发布 snapshot,再把完全相同的 prepared source 转交给后台 Agent promotion,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。 +每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能把完全相同的 prepared source 转交给后台 Agent promotion,并在交付 snapshot 前启动它,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。 ### 数据源解析与生命周期 @@ -56,7 +56,7 @@ flowchart LR 只有在不存在已挂载 Session 后,persistence absence 才映射为 Session-not-found。持久数据损坏、source identity 冲突、取消和 persistence 操作失败分别保留不同的 `SessionQueryError`,API owner 因而可以维持自身公开错误词汇,而不用重复数据源判定。 -Observation 不拥有任何 mutation 权限。其事件数组是不可变前缀,prepared Session 保持未发布。Promotion 是 Session Controller 在 opening snapshot 发出后执行的显式 ownership transfer;其他读方不能把 observation 变成 live Agent。 +Observation 不拥有任何 mutation 权限。其事件数组是不可变前缀,prepared Session 保持未发布。Promotion 是 Session Controller 在 opening 计算完成后、交付前启动的显式 ownership transfer;其他读方不能把 observation 变成 live Agent。 Projection 工作明确只有 `all | none` 两种模式。`all` 在 observation 的事件 cursor 上计算所有已注册 projection;`none` 完全不触碰 projection 状态。系统不存在按 key preparation 的状态、`projectionKeys` 模式或额外的 `viewedState`/`viewedValue` cache。发布方可以按 audience 筛选已完成的值,但底层 observation 不会处于只算完部分 projection 的状态。 @@ -72,7 +72,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view` 语料库 list 仍是独立的轻量操作。`listSessions()` 返回 live-preferred header,而不物化每份日志。Session list 先读取 live projection 状态或持久 projection-cache row。Subagent 发现通过精确 observation 读取父目录 projection。当 cache 无法判断 Session 是否为空,且该 Session 拥有的独立产物未超过配置的小日志限制时,Session list 可以执行一次完整 observation;大型或不可读的 cache miss 仍以 hints 未知但 row 可见的方式返回。 -`session.follow` 发布必需的 opening snapshot,其中包含 header、cursor、首个事件窗口和完整 projection baseline。重连使用另一份完整 snapshot 替换上一 generation。`session.page` 仅用于旧历史读取与 gap repair。只读 observation 不激活 Agent;只有普通 follow 可以保留 prepared observation,并在 opening snapshot 已交付后请求 promotion。 +`session.follow` 发布必需的 opening snapshot,包含 header、cursor、初始事件窗口与完整 projection baseline。重连通过另一份完整快照替换之前的 generation。`session.page` 支持当前或固定切点的历史读取、旧历史分页及缺口修复。只读 Observation 不激活 Agent;只有显式启用激活的普通 Follow 可以在开场计算完成后、交付之前 retain prepared observation 并请求提升。[快照与订阅决策](2026-10-21-session-history-stream-and-snapshot.zh.md)定义 Controller 组合和纯订阅语义。 ### 读取 audience @@ -82,9 +82,10 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view` |---|---|---|---| | `session.list` | Corpus header、live state 和 cached row;有界小日志 fallback | 部分 hints,或一次完整小日志 observation | 从不 | | `session.search` | Corpus 鉴权加已配置 search provider | 结果列表不计算 | 从不 | -| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅普通 cold Session,且在 snapshot 交付后 | +| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅 `activate: true` 的普通 cold Session,且在开场计算完成后、交付前 | | `session.page` | 一份精确 observation | 不计算,但 projection-backed subagent 鉴权除外 | 从不 | -| Attachment 与 fork source | 一份精确 observation | 鉴权不要求时不计算 | source 从不激活 | +| Fork source | 一份精确 observation | 鉴权不要求时不计算 | source 从不激活 | +| Attachment | 按[图片访问规则](2026-10-23-store-scoped-image-reads.zh.md)直接读取存储 | 不计算 | 从不 | | Subagent list 与 continuation | Listing 观察父目录;continuation 观察目标 | 全算;listing 消费目录值,continuation 消费身份与继承值 | Listing 从不;continuation 遵循显式命令语义 | ### 可回放的 Client 事实归 projection 所有 @@ -171,7 +172,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 ## 验证 -Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及 snapshot 交付后的 promotion。 +Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及开场计算完成后的 promotion。 Client 测试固定 higher-sequence-wins projection store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定父目录顺序、递归遍历、分支诊断、取消、fork 隔离,以及 listing 期间不激活 Agent。 @@ -179,7 +180,7 @@ Client 测试固定 higher-sequence-wins projection store、title 更新、model **由各消费方继续解析数据源。** 否决,因为每个调用方都需要重复实现 live race、persistence error mapping、preparation lifetime、cancellation 和 projection cut,既会重复工作,也会产生不一致结果。 -**每次精确读取都激活 Agent。** 否决,因为 list、history、attachment、search 与 subagent inspection 都是读取操作。Activation 会加载插件并改变进程状态,也没有适合分页或 catalog 读取的自然退出点。 +**每次精确读取都激活 Agent。** 否决,因为 list、history、search 与 subagent inspection 都是读取操作。Activation 会加载插件并改变进程状态,也没有适合分页或 catalog 读取的自然退出点。 **只 prepare 被请求的 projection key。** 否决,因为只完成部分 projection 的 Session 会增加一种生命周期状态,所有 cache、restore、plugin registration 和调用路径都必须追踪它。Projection unit 数量少且为纯函数;精确 observation 计算全部已注册 unit,比为了 `O(E*k)` 而维护部分状态、取代 `O(E*P)` 完整状态更简单。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml index 93080791e0..ed47cbd168 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml @@ -30,8 +30,8 @@ en: 9d2d50cb76754b74 zh: 9ceed3652475f18d /agent-note-historical-session-reads-prepare-before-write-publication/decision/follow-and-agent-promotion: - en: 692c29db3e7ab07e - zh: 7da0cfdbc80f407d + en: 46da45b9b63e7298 + zh: 3d55099479a80bd9 /agent-note-historical-session-reads-prepare-before-write-publication/problem-to-solution-mapping: en: e067e187cd4d0a8c zh: b0c79b635a637296 diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md index 081044e34d..4948e31a32 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md @@ -102,7 +102,7 @@ No external caller can append before the handle exists. `append`, `flush`, and ` ### Follow and Agent promotion -`session.follow` opens history through the read path, restores the Session and projections, emits the opening snapshot, and then starts Agent promotion. Agent resume uses write open, so it waits for publication before the Agent accepts a new turn. History visibility and write readiness are separate timing points without introducing an unpublished append state. +`session.follow` opens history through the read path, restores the Session and projections, starts Agent promotion from the separately retained Observation when the caller requests activation, and then delivers the opening snapshot. Agent resume uses write open, so it waits for publication before the Agent accepts a new turn. History visibility and write readiness are separate timing points without introducing an unpublished append state. ## Problem-to-solution mapping diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md index 7406e7cf31..41a87efa86 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md @@ -102,7 +102,7 @@ Handle 返回前,外部 caller 无法 append。因此 `append`、`flush` 与 ` ### Follow 与 Agent promotion -`session.follow` 通过 read path 打开历史、恢复 Session 与 projections、发出 opening snapshot,然后启动 Agent promotion。Agent resume 使用 write open,因此会在 Agent 接收新一轮对话前等待 publication。历史可见与写入就绪成为两个明确时间点,同时不引入 unpublished append state。 +`session.follow` 通过 read path 打开历史、恢复 Session 与 projections,在调用方请求激活时先用单独 retain 的 Observation 启动 Agent promotion,再交付 opening snapshot。Agent resume 使用 write open,因此会在 Agent 接收新一轮对话前等待 publication。历史可见与写入就绪成为两个明确时间点,同时不引入 unpublished append state。 ## 问题与方案对照 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index 38d42e7d93..59746973b8 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml @@ -39,8 +39,8 @@ en: ef015f605f7b00ab zh: eee9c79faebf3dff /agent-note-workspace-file-service/alternatives-considered: - en: 8eee658526db4465 - zh: 4a655accf6a5c08a + en: c750d88e0e746a99 + zh: 9e904ad4172fda49 /agent-note-workspace-file-service/consequences: en: 246bb7aed62f6c9b zh: debf6d4129ee477b diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index 02937e54e2..e475fb24b2 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -117,7 +117,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, **Serving navigable workspace documents.** Rejected on origin grounds; the [binary transfer](2026-09-17-workspace-file-binary-transfer.md) returns multipart data while HTML remains isolated in a Blob iframe. -**Log-reachable or workspace-contained authorization for file reads.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations. +**Log-reachable or workspace-contained authorization for file reads.** Requiring a Session-log reference excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations. Image attachment reads separately follow the [store-level access rule](2026-10-23-store-scoped-image-reads.md). **Whole-file read and slice for the byte window.** The interim form of `readBytes` read the file from its start to the window's end through `readBytes(target, signal, offset + length)` and sliced. It cannot read a window of a file longer than that end — the seam refuses such a file as too large — so no window could ever report `eof: false`, which contradicts the reason the method exists. Rejected in favour of the `readByteRange` seam, whose bound is the window. diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index 60b9597225..b11f7d5388 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -117,7 +117,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 **提供可导航的工作区文档。** 因源隔离问题不采用;[二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)返回 multipart 数据,HTML 仍隔离在 Blob iframe 中。 -**文件读取采用“日志可达”或工作区包含授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)改为以后端读取决策为准,只对工作区形态的操作保留包含限制。 +**文件读取采用“日志可达”或工作区包含授权。** 要求 Session 日志引用会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)以后端读取决策为准,只对工作区形态的操作保留包含限制。图片附件读取另行遵循[存储范围的访问规则](2026-10-23-store-scoped-image-reads.zh.md)。 **为字节窗口整文件读取再切片。** `readBytes` 的临时形态经 `readBytes(target, signal, offset + length)` 从文件开头读到窗口末端再切片。它读不了比该末端更长的文件的窗口——seam 会以过大拒绝这样的文件——因此没有任何窗口能报告 `eof: false`,与该方法存在的理由相悖。被否,改为以窗口为界的 `readByteRange` seam。 diff --git a/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.i18n.yaml index fae09a79ee..8d1fed4a2e 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.i18n.yaml @@ -15,8 +15,8 @@ en: eced414858f08ef9 zh: 40639593b0d5bb6f /agent-note-fatal-diagnostics-and-desktop-crash-reports/relationship-to-the-desktop-logging-design: - en: 7dc438912d10574a - zh: 07d05f9f17043d67 + en: ca4a912437c7b02e + zh: 2c7953ce5e8c196f /agent-note-fatal-diagnostics-and-desktop-crash-reports/testing: en: 864a8e71e3b3378b zh: a71807db24d9409c diff --git a/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md index 0cf5f64b89..541d22b228 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md +++ b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md @@ -32,7 +32,7 @@ This partially supersedes the [native Desktop fatal recovery](2026-09-15-desktop ## Relationship to the Desktop logging design -The Desktop local logging design (a Worker-owned rolling JSONL log with a closed event vocabulary, redaction, and rate limits) is a continuous-log design and remains future work. It stated that Host stderr and renderer console output are never persisted and that errors reach the log only as fixed categories. This decision carves out one exception: the one-time crash report of a fatal failure keeps the raw error and the retained stderr and console tails, because that raw content is what both field diagnoses needed and the report is bounded (the error section is cut at 256 KiB, the Host diagnostic at 64 KiB, the renderer console tail at 64 KiB), local, owner-only, and written only when the application is already showing its fatal dialog. The dialog names the file so a user knows what they would be sharing. The continuous-log privacy posture is unchanged for anything that is not a crash report. +Desktop continuously persists a bounded plugin-management journal with whitelisted facts, two rotating segments and a bounded write queue; its budgets and failure guarantees belong to the [Desktop README](../../../../apps/desktop/README.md). It excludes raw installation specs, error text and package-manager output. The broader Worker-owned continuous log remains deferred. Fatal crash reports independently retain raw errors and bounded stderr and renderer-console tails because these contain the field-diagnosis evidence; files are local and owner-only where supported. An isolated Worker appends bounded management history after the basic report is saved, preserving access to the basic report when collection fails or is still running. Recent operations provide context, not causal attribution. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.zh.md b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.zh.md index c3513bbddd..045134bb2e 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.zh.md @@ -32,7 +32,7 @@ macOS 上,启动审计把一组 Web 客户端行列为 `import failed (see con ## 与 Desktop 日志体系设计的关系 -Desktop 本地日志设计(Worker 独占写入的滚动 JSONL 日志、封闭事件词汇、脱敏与限流)是持续日志设计,仍是未来工作。它规定 Host stderr 与渲染进程 console 输出永不持久化、错误只以固定分类进入日志。本决定划出一个例外:致命失败的一次性崩溃报告保留原始错误以及保留的 stderr 与 console 尾部,因为两次现场定位需要的正是这些原始内容,而报告有界(error 段截至 256 KiB、Host 诊断 64 KiB、渲染进程 console 尾部 64 KiB)、本地、仅所有者可读,且只在应用已经弹出致命对话框时写入。对话框标明文件名,用户知道自己会分享什么。任何非崩溃报告的内容仍遵循持续日志的隐私姿态。 +Desktop 持续保存有界的插件管理日志,使用事实白名单、两个轮换文件段和有界写入队列;预算及失败保障由 [Desktop README](../../../../apps/desktop/README.zh.md) 定义。日志不包含原始安装 spec、错误文本或包管理器输出。更广泛的 Worker 独占持续日志仍属延期工作。致命崩溃报告独立保留原始错误以及有界的 stderr 和渲染进程 console 尾部,因为这些内容包含现场定位证据;文件保存在本地,并在平台支持时仅所有者可读。独立 Worker 在基本报告保存后追加有界管理历史,收集失败或尚未结束时仍可获取基本报告。近期操作提供上下文,不构成因果归因。 ## Testing diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml index 615a8d43cf..9ed59785ea 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml @@ -9,11 +9,11 @@ en: e42db40b879fdbf4 zh: 6017da9c4da17835 /agent-note-node-ptc-execution-modes-and-in-memory-state/decision: - en: d2d08c54c6520f02 - zh: 520a9b3421ff5c65 + en: 7b5808a31493ac1e + zh: 8d9c698c41dbf18e /agent-note-node-ptc-execution-modes-and-in-memory-state/alternatives-considered: en: 396157a9fc293198 zh: a061f05b4423e26f /agent-note-node-ptc-execution-modes-and-in-memory-state/consequences: - en: 9b9172f4aafe907f - zh: 022a6bd52d7caf05 + en: 56fa39e2958afdff + zh: d04f539437d0c00d diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md index 153a63b87e..fac34c9f7f 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md @@ -12,7 +12,7 @@ Interactive automation retains application objects, imported modules and helper The existing `@deepseek-ai/dsh-ptc-runtime-node` plugin selects its execution mode through `executionMode`. The default `per-call` mode starts a fresh process under the configured file policy. Opt-in `persistent-in-memory` mode keeps a Node REPL process per explicit, object-identity state owner. Both modes provide the same `ctx.ptcRuntime` service. PTC uses the live Agent as owner and awaits release during its context disposal. Changing the configuration reloads the provider and releases its processes. The [sandboxed Node decision](2026-09-11-sandboxed-node-ptc-runtime.md) owns per-call confinement; the [PTC foundation](../feature/2026-06-15-ptc.md) owns tool dispatch and logged model requests. -Mode names describe state retention rather than a process implementation or source syntax. `per-call` retains state for one invocation; `persistent-in-memory` retains it across invocations until the process or owner ends. `durable` names the deferred ability to save and restore execution state across restarts. It is not an accepted configuration value: a future implementation must define its recovery point, recoverable values and external-resource handling before exposing that mode. The [retained-process permission decision](2026-10-08-persistent-ptc-sandbox.md) owns confinement, policy changes and temporary escalation. +The `per-call` and `persistent-in-memory` names describe their fixed state-retention policies: one invocation or across invocations until the process or owner ends. The [Script evaluation decision](2026-10-20-ptc-script-evaluation-and-retention.md) extends these modes with independently configured syntax and retention. `durable` names the deferred ability to save and restore execution state across restarts. It is not an accepted configuration value: a future implementation must define its recovery point, recoverable values and external-resource handling before exposing that mode. The [retained-process permission decision](2026-10-08-persistent-ptc-sandbox.md) owns confinement, policy changes and temporary escalation. One npm package and one plugin entry keep existing installation and loading paths stable. Communication, binding proxies, JSON encoding and output limits remain package-local modules. The Node provider delegates retained process ownership to an internal execution object, which registers no additional Cordis service. Per-call and in-memory execution keep their distinct lifecycle rules. `programFormat` describes source evaluation independently of retention: a REPL need not retain state. The runtime owns syntax, completion and retention instructions through `executionInstructions`; tools own call arguments, SDK declarations and image attachment. Workflow uses `programFormat` to wrap its program and refuses restricted file policies when the selected runtime cannot enforce them. @@ -34,4 +34,4 @@ A realm inside the Harness process shares process lifetime with model code and c Selecting `persistent-in-memory` retains execution state; the [permission decision](2026-10-08-persistent-ptc-sandbox.md) defines its file restrictions. Nested tools keep their normal authorization and logging. Memory and native handles may disappear without a Harness restart. The model must recover from reference or resource errors; the runtime never retries a program automatically. -An idle owner retains a process with no expiration timer. Callers release owners explicitly; ownerless service calls use temporary processes. Programs use REPL statements and a final expression rather than top-level return, and calls for one retained owner cannot overlap. Tool schemas and program guidance follow the selected mode. +An idle owner retains a process with no expiration timer. Callers release owners explicitly; ownerless service calls use temporary processes. The `persistent-in-memory` mode uses REPL statements and a final expression rather than top-level return, and calls for one retained owner cannot overlap. Tool schemas and program guidance follow the selected mode. diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md index a4c4a52828..93959fe07a 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md @@ -12,7 +12,7 @@ Status: implemented 现有 `@deepseek-ai/dsh-ptc-runtime-node` 插件通过 `executionMode` 选择执行模式。默认的 `per-call` 模式按配置的文件策略启动新进程。显式启用的 `persistent-in-memory` 模式为每个以对象身份标识的状态所有者保留一个 Node REPL 进程。两种模式都提供同一个 `ctx.ptcRuntime` 服务。PTC 使用存活的 Agent 作为所有者,在其上下文销毁时等待释放。修改配置会重载提供方并释放其进程。[Node 沙箱决策](2026-09-11-sandboxed-node-ptc-runtime.zh.md)负责逐次执行的文件约束,[PTC 基础决策](../feature/2026-06-15-ptc.zh.md)负责工具分派与模型请求日志。 -模式名称描述状态保留方式,不绑定进程实现或代码语法。`per-call` 只为一次调用保留状态;`persistent-in-memory` 跨调用保留状态,直到进程或所有者结束。`durable` 用于命名未来跨重启保存与恢复执行状态的能力。它不是可接受的配置值:实现必须先明确恢复点、可恢复的值以及外部资源的处理方式,才能公开这个模式。[常驻进程权限决策](2026-10-08-persistent-ptc-sandbox.zh.md)负责文件约束、权限切换与临时提权。 +`per-call` 和 `persistent-in-memory` 名称描述各自固定的状态保留策略:只保留一次调用,或跨调用保留至进程或所有者结束。[Script 求值决策](2026-10-20-ptc-script-evaluation-and-retention.zh.md)在这两种模式之外增加独立配置的语法与保留方式。`durable` 用于命名未来跨重启保存与恢复执行状态的能力。它不是可接受的配置值:实现必须先明确恢复点、可恢复的值以及外部资源的处理方式,才能公开这个模式。[常驻进程权限决策](2026-10-08-persistent-ptc-sandbox.zh.md)负责文件约束、权限切换与临时提权。 一个 npm 包和一个插件入口保持现有安装与加载路径稳定。通信、绑定代理、JSON 编码和输出限制继续作为包内模块复用。Node 提供方将保留进程的所有权交给内部执行对象,该对象不注册额外的 Cordis 服务。逐次执行和内存保留各自管理不同的生命周期。`programFormat` 描述代码求值形式,与状态保留独立:REPL 不必跨调用保留状态。运行时通过 `executionInstructions` 负责语法、完成值和状态保留指引;工具层负责调用参数、SDK 声明和图片附加。工作流根据 `programFormat` 包装程序;所选运行时无法执行受限文件策略时,工作流拒绝执行。 @@ -34,4 +34,4 @@ Harness 进程内的独立执行上下文与模型代码共享进程生命周期 选择 `persistent-in-memory` 会保留执行状态;[权限决策](2026-10-08-persistent-ptc-sandbox.zh.md)定义其文件限制。嵌套工具仍遵循原有授权和日志规则。即使 Harness 没有重启,内存和原生句柄也可能丢失。模型需要从引用或资源错误中恢复,运行时不会自动重试程序。 -空闲所有者保留一个没有过期计时器的进程。调用方显式释放所有者,没有所有者的服务调用使用临时进程。程序使用 REPL 语句和末尾表达式,不支持顶层 return;同一保留所有者的调用不能重叠。工具 schema 和程序指引跟随所选模式。 +空闲所有者保留一个没有过期计时器的进程。调用方显式释放所有者,没有所有者的服务调用使用临时进程。`persistent-in-memory` 模式使用 REPL 语句和末尾表达式,不支持顶层 return;同一保留所有者的调用不能重叠。工具 schema 和程序指引跟随所选模式。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index a644575d18..4cf8989458 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -36,8 +36,8 @@ en: d50ae22978165c57 zh: 8c1fcfb7dc2d3367 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources: - en: 056a141b31270cba - zh: 3f6caafb3a13c192 + en: 582f5f527ecc58c8 + zh: 4a4b68361de2dcf0 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access: en: e57f396239ea9f56 zh: ddb26278b1521ceb diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index 6b1cc15750..7c13d393f3 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -241,7 +241,7 @@ The table lists the endpoints #3854 viewers use and where their resources come f | `llm/listProviders` | `global` | no resource | no | | `commands/list` | session: `args.agentId` (the Agent lookup's wire field) | Agent lookup `resource: 'session'` | no | | `goals/get` | session: `args.agentId` | Agent lookup `resource: 'session'` | no | -| `session/attachment` | session: `args.request.sessionId` | default resolver | no | +| `session/attachment` | session: `args.request.sessionId` | default resolver | no: a grant on one Session permits reads from the whole attachment store | | `skills/list` | session: `args.request.sessionId` | default resolver | no | | `session/fork` | session: `args.request.sessionId` | default resolver | no | | `session/prompt` | session: `args.request.sessionId` | default resolver | no | @@ -251,6 +251,8 @@ The table lists the endpoints #3854 viewers use and where their resources come f | `session/follow` | same as above | default resolver | no | | `remoteAccess/follow` | `global` | no resource; remote-access allows it for every principal | no | +For `session/attachment`, `sessionId` identifies the resource for the Gateway check; it does not constrain which image may be read. Once that check permits the call, an image may belong to another Session or no Session, and the handler does not check whether the supplied Session exists. Policy authors must treat this action as access to the whole attachment store, as defined by the [image access rule](2026-10-23-store-scoped-image-reads.md). + A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource. Methods that need no non-owner access declare nothing, and a method no role lists is refused. The request fields of subagent `prompt` are `parentSessionId` / `childSessionId`, so automatic resolution finds no Session and resolves it to `global`; it must therefore stay out of every `global` role. `session/search` filters its hits per Session inside the method. `subagents/list` is not a Remote endpoint (`SubagentRuntime.list()` carries no `@Remote`). diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index 00e418b8c7..9503b430aa 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -241,7 +241,7 @@ export function RemoteScope | `llm/listProviders` | `global` | 无资源 | 否 | | `commands/list` | session:`args.agentId`(Agent lookup 的 wire 字段) | Agent lookup `resource: 'session'` | 否 | | `goals/get` | session:`args.agentId` | Agent lookup `resource: 'session'` | 否 | -| `session/attachment` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/attachment` | session:`args.request.sessionId` | 默认 resolver | 否:一条 Session 上的授权允许读取整个附件存储 | | `skills/list` | session:`args.request.sessionId` | 默认 resolver | 否 | | `session/fork` | session:`args.request.sessionId` | 默认 resolver | 否 | | `session/prompt` | session:`args.request.sessionId` | 默认 resolver | 否 | @@ -251,6 +251,8 @@ export function RemoteScope | `session/follow` | 同上 | 默认 resolver | 否 | | `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 | +对于 `session/attachment`,`sessionId` 指定 Gateway 检查的资源,不限制可读取哪张图片。该检查放行后,图片可以属于其他 Session 或不属于任何 Session,处理函数也不检查请求中的 Session 是否存在。policy 作者必须把此 action 视为整个附件存储的读取权限,具体规则见[图片访问规则](2026-10-23-store-scoped-image-reads.zh.md)。 + subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。 不需要非 owner 访问的方法不做任何声明,没有 role 列出它即被拒。subagent `prompt` 的请求字段为 `parentSessionId` / `childSessionId`,自动解析不到会话,解析为 `global`,因此不得放进任何 `global` 级 role;`session/search` 在方法内按会话逐项过滤命中。`subagents/list` 不是 Remote 端点(`SubagentRuntime.list()` 未标 `@Remote`)。 diff --git a/.agents/notes/implemented/architecture/2026-10-20-ptc-script-evaluation-and-retention.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-20-ptc-script-evaluation-and-retention.i18n.yaml new file mode 100644 index 0000000000..89f47b3ea8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-ptc-script-evaluation-and-retention.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-20-ptc-script-evaluation-and-retention.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-20-ptc-script-evaluation-and-retention.md +/agent-note-ptc-script-evaluation-and-state-retention: + en: e222c8cda4cee561 + zh: 9e624e5643ad2984 +/agent-note-ptc-script-evaluation-and-state-retention/problem: + en: 2da844a2a4e1903b + zh: 3b74ba7c1bcdae89 +/agent-note-ptc-script-evaluation-and-state-retention/decision: + en: 4beff8714f1d1555 + zh: a22019a90989eb66 +/agent-note-ptc-script-evaluation-and-state-retention/alternatives-considered: + en: 8ee3f3c7e04b4f25 + zh: ac604fa8debcfebf +/agent-note-ptc-script-evaluation-and-state-retention/consequences: + en: 64b7d3a5d247d82b + zh: 6f94d6b37f1ea4c1 diff --git a/.agents/notes/implemented/architecture/2026-10-20-ptc-script-evaluation-and-retention.md b/.agents/notes/implemented/architecture/2026-10-20-ptc-script-evaluation-and-retention.md new file mode 100644 index 0000000000..ef8a85dbfb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-ptc-script-evaluation-and-retention.md @@ -0,0 +1,33 @@ +# Agent Note: PTC Script evaluation and state retention + +Status: implemented + +English | [中文](2026-10-20-ptc-script-evaluation-and-retention.zh.md) + +## Problem + +Program syntax and memory lifetime answer different questions. A consumer may need Script declarations and completion values while requiring either a fresh environment or retained references. Inferring memory lifetime from REPL syntax prevents that choice. + +## Decision + +The Node provider adds `executionMode: vm-script` and a separate `scriptPersistence` option, defaulting to false. `PtcRuntime.programFormat` describes accepted syntax; `retainsState` describes owner-scoped memory. Consumers use the latter for persistence controls and guidance. Existing `per-call` and `persistent-in-memory` configurations retain their meanings and defaults. + +This extends the mode-naming decision in [best-effort persistent PTC](2026-09-23-best-effort-persistent-ptc.md), whose ownership, non-durability and recovery decisions remain active. The Node provider selects its evaluator once and shares managed process mechanisms across temporary and retained lifetimes. AsyncFunction, REPL and Script remain separate evaluator implementations, so process reuse does not define syntax. + +VM Script uses a separate realm inside the managed child process. Top-level await uses the Node.js v26.6.0 REPL transform, with Acorn and acorn-walk supplying parsing and traversal. Detection precedes native compilation because non-strict Script can compile `await` as an identifier. The adapted visitors traverse computed method keys without entering method bodies. The source retains upstream attribution and license; the [package reference](../../../../packages/ptc-runtime/ptc-runtime-node/README.md#known-limitations-and-deferred-work) owns declaration and realm limitations. + +## Alternatives considered + +**Replace existing modes with Script.** This would change async-function return syntax and REPL declaration behavior for existing consumers. An additional mode preserves those choices while exposing Script semantics. + +**Encode syntax and retention in every mode name.** Separate names for fresh and retained Script would repeat syntax choices and encourage consumers to infer persistence from the format. One Script mode and an explicit retention option keep each fact separately observable. + +**Use native Script without await conversion.** Native Script cannot evaluate top-level await. Parsing only after native compilation fails also misses valid programs where `await` compiles as an identifier. The Node transform supplies retained declarations and completion handling while keeping its known semantic tradeoffs explicit. + +**Own a generic AST walker.** A local walker duplicates traversal provided by acorn-walk and must independently track new Acorn nodes. The maintained walker removes that implementation; only the evaluation-specific visitors remain local. + +## Consequences + +Script state is best-effort process memory, with the same explicit owner release and sandbox invalidation rules as retained REPL execution. It is neither serialized nor reconstructed by rerunning earlier programs. An uncaught callback exception or unhandled rejection terminates the Script process; programs must await asynchronous work. + +The published format union now includes `script`, and custom retained providers must report `retainsState`. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.md) describes consumer migration. Source deployments need the evaluator modules and Acorn packages; private process bundles include those dependencies. diff --git a/.agents/notes/implemented/architecture/2026-10-20-ptc-script-evaluation-and-retention.zh.md b/.agents/notes/implemented/architecture/2026-10-20-ptc-script-evaluation-and-retention.zh.md new file mode 100644 index 0000000000..1dcdf4f1bc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-ptc-script-evaluation-and-retention.zh.md @@ -0,0 +1,33 @@ +# Agent Note: PTC Script 求值与状态保留 + +Status: implemented + +[English](2026-10-20-ptc-script-evaluation-and-retention.md) | 中文 + +## 问题 + +程序语法和内存生命周期回答不同的问题。消费者可能需要 Script 声明和完成值,同时要求全新环境或保留引用。根据 REPL 语法推断内存生命周期会阻碍这种选择。 + +## 决策 + +Node 提供方增加 `executionMode: vm-script` 和独立的 `scriptPersistence` 选项,后者默认为 false。`PtcRuntime.programFormat` 描述接受的语法,`retainsState` 描述按所有者保留的内存。消费者通过后者提供持久化控制和指引。已有 `per-call` 和 `persistent-in-memory` 配置保留原有含义与默认值。 + +本决策扩展[尽力保留 PTC 状态](2026-09-23-best-effort-persistent-ptc.zh.md)中的模式命名决策;其中的所有权、非持久存储及恢复决策仍然有效。Node 提供方一次性选择求值器,并在临时和保留生命周期之间共用受管进程机制。AsyncFunction、REPL 和 Script 仍是独立的求值器实现,进程复用不决定语法。 + +VM Script 在受管子进程内使用独立 realm。顶层 await 使用 Node.js v26.6.0 REPL 转换,由 Acorn 和 acorn-walk 提供解析与遍历。在原生编译之前检测 await,因为非严格 Script 可以将 `await` 编译为标识符。适配后的 visitor 遍历方法的计算属性名,但不进入方法体。源码保留上游署名和许可;[包参考](../../../../packages/ptc-runtime/ptc-runtime-node/README.zh.md#known-limitations-and-deferred-work)负责声明与 realm 限制。 + +## 考虑过的替代方案 + +**用 Script 替换已有模式。** 这会改变现有消费者的异步函数返回语法和 REPL 声明行为。增加模式可在提供 Script 语义的同时保留已有选择。 + +**在每个模式名称中同时编码语法与保留方式。** 为全新和保留状态的 Script 分别命名,会重复语法选项,并鼓励消费者根据格式推断持久化。一个 Script 模式加显式保留选项,让两项事实可以分别观测。 + +**仅使用原生 Script,不转换 await。** 原生 Script 无法求值顶层 await。只在原生编译失败后解析,也会漏掉将 `await` 编译为标识符的有效程序。Node 转换提供声明保留和完成值处理,同时明确其已知语义取舍。 + +**自行维护通用 AST 遍历器。** 局部遍历器重复 acorn-walk 已提供的遍历功能,还必须独立跟踪新增 Acorn 节点。使用维护中的遍历器可以删除这部分实现,只保留与求值相关的局部 visitor。 + +## 影响 + +Script 状态是尽力保留的进程内存,遵循与保留 REPL 相同的显式所有者释放和沙箱失效规则。状态不会序列化,也不会通过重新执行旧程序重建。未捕获的回调异常或未处理的 Promise 拒绝会终止 Script 进程;程序必须等待异步工作。 + +公开格式联合类型现包含 `script`,自定义保留状态的提供方必须报告 `retainsState`。[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.zh.md)说明消费者迁移。源码部署需要求值器模块和 Acorn 包,私有进程 bundle 包含这些依赖。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml new file mode 100644 index 0000000000..9ae0452069 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-21-session-history-stream-and-snapshot.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation: + en: 0c9a7bc6591feb16 + zh: 3606a2c1caffd36f +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/problem: + en: ac1583e7ae1377f8 + zh: c60bdbda9ee89144 +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision: + en: 45c63df5dac389eb + zh: d72a5bcb94de22f1 +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/reads-and-subscriptions: + en: 38df4f3c4fba2cc9 + zh: a34f3376faf26f0c +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/follow-ordering-and-ownership: + en: 352ff87a3e9cd110 + zh: 4ac6366cecfc08a3 +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/state-and-wait: + en: a87c7427582e6b03 + zh: b28d2a73b36eb27f +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/alternatives-considered: + en: c5fb06ab9df9e7ad + zh: b20adf151018111c +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/consequences: + en: dc1da262b4911a31 + zh: 2a4e503696fc477b diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md new file mode 100644 index 0000000000..b5e1112a18 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md @@ -0,0 +1,59 @@ +# Agent Note: Session snapshots, passive subscriptions, and Web aggregation + +Status: implemented + +English | [中文](2026-10-21-session-history-stream-and-snapshot.zh.md) + +## Problem + +A point read, an event subscription, and a Web conversation opening have different lifetimes. Combining them inside one history controller makes passive observation perform unnecessary reads and gives each aggregate its own acquisition logic. Reading before subscribing also creates a gap that requires another read to repair. The APIs need independent operations that can compose without losing events or duplicating ownership. + +## Decision + +`SessionController` is the single Session Remote Service. It owns ordinary `HistoryController`, `StateController`, and `WebController` instances, not three additional Cordis Services or Remote namespaces. The implementation separates six operations: history snapshot and observation, projection snapshot and observation, and state snapshot and observation. Follow and Wait compose those capabilities for their callers; the existing `page` entry forwards to the history reader. + +### Reads and subscriptions + +History reads return chronological records, `hasMore`, and an inclusive cursor. An omitted `throughSeq` selects the observed log end; `beforeSeq` selects older records without changing that read cut. Projection reads return exact sequenced values, sequence-free cache hints when migration is required, or null when the Session is absent. Live projection reads avoid persistence metadata access. + +`usingObservation({ sessionId, projection }, signal, callback?)` acquires the existing disposable SessionObservation. A callback borrows it until the callback settles; without a callback the caller owns disposal. Ordinary history, state, and Wait output reads do not request projections. Follow, exact projection reads, and subagent history descriptor validation request them. No read activates an Agent implicitly. + +`observeEvent` and `observeProjections` install listeners without acquiring an Observation. Their initial `ready` confirms subscription, not a snapshot cursor, existence, parent-child validity, or Agent readiness. History establishes continuity from its first received event; projection updates replace individual keys and may share a sequence number. `observeState` instead begins with a complete state read and coalesces later Session and Agent invalidations. + +History, history observation, and Follow accept Address requests; projection and state operations accept Session IDs. All internal readers and listeners use Session IDs. History and Follow validate Address against their opening Observation at the Controller layer, including the existing subagent descriptor rules. Pure history observation does not acquire a read for entity validation. Gateway resource authorization remains independent: the registered Session resolver extracts the target ID from the request before access is decided. + +### Follow ordering and ownership + +Follow installs history and optional assistant listeners first, then acquires one projected Observation. It captures the assistant baseline and binding-local arrival ordinal synchronously, sets both cuts, publishes the opening snapshot, and consumes the shared output queue. History notifications at or before the snapshot cursor and assistant notifications at or before the ordinal are discarded at consumption. Later notifications remain ordered without a second history read, background merge pumps, or a tail repair introduced by the opening itself. + +`session/created` contributes constructor seed events through the same history buffer. The adapter retains the supplied Session and its captured end position, then reads that in-memory prefix when consumed; it does not acquire another Observation. Each binding owns its subscriptions; a shared output queue belongs to Follow rather than to either listener. + +Follow activates only when the caller passes `activate: true`; the Web Client does so explicitly. Background promotion starts once the opening is computed and before it is delivered, and does not promise Agent readiness. Ordinary promotion reuses the existing Agent controller and receives a separately retained Observation. Either Follow releases its opening Observation once the opening is computed, before yielding it; activation continues on the separately retained lease, so the cold entry's reference count drops to that lease alone. Subagent Sessions do not use ordinary promotion. + +### State and Wait + +State contains the event cursor, active and running flags, the complete readable queue or null, and pending-input kinds. It exposes neither a header nor an internal revision. Gateway owns unsettled Remote events; State samples their detached metadata and classifies approval, question, or other input without copying a request registry. + +Wait evaluates complete states, confirms active-Agent quiescence with `whenIdle`, and rejects stale observations using listener-local revisions and Agent/Session identity checks. Session and Agent events wake reads immediately; while waiting, a one-second recheck detects pending input that produces no such event. This avoids adding a Gateway change event, at the cost of polling and bounded sampling delay rather than instantaneous notification. `observeState` does not poll, so pending-only changes need another state invalidation to become visible there. + +Wait returns idle, input-required, or timeout with the last state it accepted and bounded assistant output. Output contains only `assistant/message` records in `(afterSeq, state.cursor]`, newest first, and reports truncation only when another eligible record exists. The caller's cancellation signal controls that output read independently of the expired wait deadline. A wait that accepts no state before its deadline fails instead of inventing a state. + +## Alternatives considered + +**Read history, then subscribe and repair the gap.** Subscription-before-read already captures opening-time increments. A second read adds acquisition and replay obligations without improving that ordering. + +**Give pure subscriptions an Observation or ready cursor.** These values conflate subscription readiness with an independently selected read cut. Callers that need both subscribe and buffer first, then read and discard covered increments. + +**Keep separate Remote Services for the implementation controllers.** The classes divide behavior, not external service identity. One Session Service owns their composition, resource policy, and Remote entry points. + +**Have Gateway interpret pending input or emit Session status.** Gateway only owns Remote event transport and unsettled-event metadata. State owns classification; Wait's periodic sampling avoids adding a transport notification solely for Session waiting. + +**Merge assistant and history with independent asynchronous pumps.** One caller-owned buffer retains cross-source arrival order without extra pump cancellation or queue ownership. Snapshot construction and per-binding buffering retain distinct lifetimes. + +## Consequences + +The standard reads and Web aggregate share one Observation acquisition path, while pure subscriptions remain read-free. The old combined History controller is removed; still-used pagination, validation, and wire helpers remain at their original path. Cancellation, consumer termination, failed opening reads, and Context disposal release subscriptions and owned leases without cancelling Agent execution. + +The [Observation decision](2026-08-25-session-observations-and-projection-owned-client-state.md) remains the authority for exact read cuts and projection derivation. The [history and Remote transport decision](2026-08-18-session-history-and-event-transport.md) retains ownership of Client reconnection and transport lifetimes. This decision refines Session API composition rather than replacing either broader decision. + +The migrated history, cold-read, and transport tests exercise the new classes, including cursor overlap, assistant ordinal cuts, sequence gaps, subagent validation, and teardown. Slow consumers can still retain queued notifications; silent dropping is not a supported recovery strategy. Independent completion-acknowledged activation is not implemented by these reads or by Follow's background activation option. diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md new file mode 100644 index 0000000000..773ab039b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md @@ -0,0 +1,59 @@ +# Agent Note: Session 快照、纯订阅与 Web 聚合 + +Status: implemented + +[English](2026-10-21-session-history-stream-and-snapshot.md) | 中文 + +## Problem + +定点读取、事件订阅和 Web 会话开场具有不同的生命周期。把它们合并在同一个历史控制器中,会使被动观察执行不必要的读取,并让每个聚合各自维护获取数据的逻辑。先读后订阅还会产生需要再次读取来修复的缺口。接口需要相互独立的操作,使组合既不丢事件,也不重复资源所有权。 + +## Decision + +`SessionController` 是唯一的 Session Remote Service。它持有普通的 `HistoryController`、`StateController` 和 `WebController` 实例,而不是额外的三个 Cordis Service 或 Remote namespace。实现分离六个操作:历史快照与观察、投影快照与观察、状态快照与观察。Follow 和 Wait 为调用方组合这些能力;已有的 `page` 入口转发到历史读取器。 + +### 读取与订阅 + +历史读取返回时间正序的记录、`hasMore` 和包含末项的游标。省略 `throughSeq` 时选择观察到的日志末尾;`beforeSeq` 只选择更早的记录,不改变读取点位。投影读取返回精确的有序值、需要迁移时无序号的缓存提示,或在 Session 不存在时返回 null。live 投影读取不访问持久化元信息。 + +`usingObservation({ sessionId, projection }, signal, callback?)` 获取已有的可释放 SessionObservation。有回调时租约借给回调直到其结束;无回调时由调用方释放。普通历史、状态及 Wait 输出读取不请求投影。Follow、精确投影读取和子会话历史 descriptor 校验请求投影。读取不会隐式激活 Agent。 + +`observeEvent` 和 `observeProjections` 安装监听而不获取 Observation。首个 `ready` 确认订阅生效,不代表快照游标、实体存在、父子关系有效或 Agent 就绪。历史从首个收到的事件建立连续性;投影更新替换单个键,多个更新可以共享序号。`observeState` 则从完整状态读取开始,并合并后续 Session 和 Agent 失效通知。 + +历史、历史观察和 Follow 接收 Address 请求;投影和状态操作接收 Session ID。所有内部读取器和监听器都使用 Session ID。History 和 Follow 在 Controller 层以开场 Observation 校验 Address,包括已有的子会话 descriptor 规则。纯历史观察不为实体校验获取读取。Gateway 资源授权与之独立:注册的 Session 解析器在访问判定前从请求提取目标 ID。 + +### Follow 顺序与所有权 + +Follow 先安装历史和可选 assistant 监听,再获取一份带投影的 Observation。它同步捕获 assistant 基线和绑定局部的到达序号,设置两种截断点,发布开场快照,然后消费共享输出队列。消费时丢弃序号不超过快照游标的历史通知,以及到达序号不超过截断点的 assistant 通知。后续通知保持顺序,无需第二次历史读取、后台合并任务,或由开场自身引入的尾部修复。 + +`session/created` 经同一历史缓冲提供构造期种子事件。适配器保留收到的 Session 及捕获的末尾位置,在消费时读取对应内存前缀,不获取另一份 Observation。每个绑定拥有自己的订阅;共享输出队列属于 Follow,而不是任一监听器。 + +只有调用方传入 `activate: true` 时 Follow 才激活;Web Client 显式传入该参数。后台提升在开场计算完成后、交付之前启动,不承诺 Agent 已就绪。普通提升复用已有 Agent controller,并接收单独 retain 的 Observation。两种 Follow 都在开场计算完成后、交付前释放开场 Observation;激活则继续使用单独 retain 的租约,因此冷 entry 的引用计数只剩那一份。子会话不走普通提升。 + +### 状态与 Wait + +State 包含事件游标、active 与 running 标志、完整可读队列或 null,以及待输入种类。它不暴露 header 或内部 revision。Gateway 拥有未结算的 Remote 事件;State 采样独立元数据,将输入分类为 approval、question 或 other,不复制请求注册表。 + +Wait 判断完整状态,通过 `whenIdle` 确认活跃 Agent 已静止,并通过监听器局部 revision 和 Agent/Session 身份检查拒绝过时观察。Session 和 Agent 事件立即唤醒读取;等待期间每秒重查一次,检测没有产生上述事件的待输入请求。这避免新增 Gateway change 事件,代价是轮询和采样延迟,而非即时通知。`observeState` 不轮询,因此仅有 pending 变化时,需要另一项状态失效通知才会显示更新。 + +Wait 返回 idle、input-required 或 timeout,以及最后一次被接受的状态和有界 assistant 输出。输出仅包含 `(afterSeq, state.cursor]` 内从新到旧的 `assistant/message` 记录,只有存在另一条符合条件的记录时才报告截断。输出读取使用调用方的取消信号,不受已过期的等待截止信号控制。截止前没有任何状态通过校验的 wait 会失败,不构造虚假的状态。 + +## Alternatives considered + +**先读历史,再订阅并修复缺口。** 先订阅后读取已经捕获开场期间的增量。第二次读取增加资源获取和重放责任,却不改善这个顺序。 + +**给纯订阅提供 Observation 或 ready 游标。** 这会混淆订阅就绪和独立选取的读取点位。需要两者的调用方先订阅并缓冲,再读取并丢弃已覆盖增量。 + +**为各实现 Controller 保留独立 Remote Service。** 这些类划分行为,而不是外部服务身份。一个 Session Service 拥有其组合、资源策略和 Remote 入口。 + +**让 Gateway 解释待输入状态或发送 Session status。** Gateway 只拥有 Remote 事件传输和未结算事件元数据。State 拥有分类;Wait 定期采样,避免仅为 Session 等待增加传输通知。 + +**用独立异步任务合并 assistant 与历史。** 调用方拥有的单个缓冲保留跨来源到达顺序,不引入额外任务取消或队列所有权。快照构造和各绑定缓冲仍保留不同生命周期。 + +## Consequences + +标准读取和 Web 聚合共享同一 Observation 获取路径,纯订阅不读取数据。旧的合并 History controller 已删除;仍使用的分页、校验和 wire 辅助函数留在原路径。取消、消费者结束、开场读取失败和 Context 释放都会释放订阅及拥有的租约,而不取消 Agent 执行。 + +[Observation 决策](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)仍拥有精确读取点位和投影派生规则。[历史与 Remote 传输决策](2026-08-18-session-history-and-event-transport.zh.md)继续拥有 Client 重连和传输生命周期。本决策细化 Session API 组合,而不取代上述更广泛的决策。 + +迁移后的历史、冷读取和传输测试覆盖新类,包括游标重叠、assistant 到达序号截断、序号缺口、子会话校验和释放。慢消费者仍可能积累队列通知;静默丢弃不是支持的恢复策略。这些读取以及 Follow 的后台激活选项均未实现独立的、确认完成后返回的激活接口。 diff --git a/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.i18n.yaml new file mode 100644 index 0000000000..0acdacdd56 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-23-store-scoped-image-reads.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md +/agent-note-image-reads-use-attachment-store-access: + en: 31c8a0d9016d99af + zh: 05db308dec85db67 +/agent-note-image-reads-use-attachment-store-access/problem: + en: ff8886cfa4634163 + zh: 46dfdd50638b64c2 +/agent-note-image-reads-use-attachment-store-access/decision: + en: 439a3277ee3b169d + zh: c9f1e10254850844 +/agent-note-image-reads-use-attachment-store-access/alternatives-considered: + en: 0c7a8319c0c91dc2 + zh: 6b7b58b142839571 +/agent-note-image-reads-use-attachment-store-access/consequences: + en: a46c3d951077ec7d + zh: 30a0cc5bcb681a21 +/agent-note-image-reads-use-attachment-store-access/verification: + en: 89603b3a4c3e12df + zh: f1b01d166f399f87 diff --git a/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md new file mode 100644 index 0000000000..8805102882 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md @@ -0,0 +1,52 @@ +# Agent Note: Image reads use attachment-store access + +Status: implemented + +English | [中文](2026-10-23-store-scoped-image-reads.zh.md) + +## Problem + +Historical image loading read the requesting Session and searched its event content for every uncached attachment. The search established that the Session referenced the image; it did not locate the bytes, whose storage path already follows from the content-addressed ID. This added work proportional to history length to each image read. The reported loading delay also involved uploads; this finding alone does not identify all of that delay. + +## Decision + +Session membership is not an image-access restriction. After connection authentication and Gateway permission checks authorize `session/attachment`, every caller may read any image in the same attachment store using a complete `ImageAttachmentRef`, including objects referenced only by other Sessions or not referenced at all. The request retains `sessionId` for the existing Session resource permission check. After that check passes, the handler does not require that Session to exist and does not return `session/not-found` solely because it is absent. No identity receives a separate history-scanning path. + +The Client already holds the message's complete image reference and sends it as `attachment` beside `sessionId`. Session Controller delegates directly to the existing `AttachmentStore.readImage(ref)`. Its local provider validates the canonical ID, derives the path within its object directory, verifies SHA-256, and compares the actual format, byte length, width, and height with the supplied reference. A missing, unreadable, corrupt, or mismatched object rejects the operation. No Session history is needed to recover the reference. + +The response preserves the supplied reference. Its optional `name` and `originalDimensions` are caller-supplied display information and are not verified against the stored object. The Client's existing historical-image cache continues to own per-Session results and object URLs; the Host adds no image-byte cache. + +This rule narrows the readers covered by [Session observations](2026-08-25-session-observations-and-projection-owned-client-state.md) and the [history transport activation policy](2026-08-18-session-history-and-event-transport.md). [Workspace file reads](2026-09-05-workspace-files-service.md) keep their own filesystem authorization. + +## Alternatives considered + +**Keep a Session-to-image map or projection.** An index removes repeated scans but preserves an access restriction the product does not require, adding derivation, lifetime, and invalidation work. + +**Issue signed image tickets.** Tickets add issuance and validation state for the same unnecessary Session-membership restriction. + +**Optimize only owner reads.** That gives identities different image-access semantics and leaves the scan cost for permitted non-owner callers without a product requirement. + +## Consequences + +Knowing a valid image reference and having permission to invoke the endpoint for the supplied Session resource permits reading that object across Sessions. The local ID is the SHA-256 of normalized bytes. For known PNG, JPEG, or WebP bytes that admission preserves unchanged, a caller can calculate the ID, derive the format, byte length, and dimensions, and use an authorized read to test whether the object exists. The required reference fields need not come from a Session log. + +Image isolation requires separate Host instances with separate `DSH_HOME` directories. The shipped composition uses one attachment store per Host and does not select a store by Session. Authentication, Gateway action/resource checks, fixed storage paths, digest verification, and explicit failures remain required. + +Image reads no longer depend on history length or Session materialization. File reading, integrity verification, encoding, and transport still cost time; this change makes no claim about upload or model inference latency. Existing Session data and fork references need no migration. + +## Verification + +The read-path invariant is zero Session-history queries or traversal for attachment reads. Coverage must distinguish a permitted cross-Session image read from a denied Gateway call and exercise valid references, invalid IDs, mismatched intrinsic metadata, preserved optional display fields, missing and corrupt objects, old history pages, and inherited fork images. Performance evidence must separate historical scanning, storage work, and transport rather than attribute the entire reported delay to the scan. + +The diagnostic is [`image-read.perf.ts`](../../../../packages/api/session-controller/tests/image-read.perf.ts), compiled by its [tsdown configuration](../../../../packages/api/session-controller/tests/image-read.perf.config.ts). The baseline worker pairs the pre-change controller with `{ sessionId, attachmentId: ref.attachmentId }`; the candidate worker pairs the current controller with `{ sessionId, attachment: ref }`. Both use the same image fixtures, workload, and output checks, and run against the same built Local store with `readImage(ref)` verification. From the candidate checkout root, compile and run: + +```sh +pnpm exec tsdown --config-loader native --config packages/api/session-controller/tests/image-read.perf.config.ts +(cd packages/api/session-controller && node --expose-gc .dsh-build/image-read/image-read.perf.js --expect-no-history) +``` + +Run the separately compiled baseline worker without `--expect-no-history`; its diagnostic request must match the baseline controller's ID-only request fields. The baseline negative control adds `--sample typical --expect-no-history` and must fail because that implementation queries and traverses history. The parent process runs each workload in five fresh child processes, each with a loaded Session and a first uncached read of every image. Images are deterministic synthetic 1024 × 768 PNGs; the two workloads contain 120 turns, 960 events, and 8 images, or 2,000 turns, 16,000 events, and 32 images. Fixture writes can leave the operating system's file cache warm. + +`controllerMs` covers the direct controller call through base64 return. `storeMs` includes file I/O, SHA-256, and header verification; `snapshotMs` measures history snapshot acquisition separately. `scanAndDispatchMs` is the time before store entry minus `snapshotMs`, so it includes dispatch overhead and is not an isolated scan timer. JSON serialization and loopback HTTP transfer are separate measurements over retained responses. `requestJsonBytes` and `idOnlyRequestJsonBytes` compare serialized request lengths; they do not measure request transmission time. Cold Session restoration, Gateway processing, browser rendering, remote-network latency, upload, and model inference are excluded. PR #7021 records the worker revisions and before/after results under these limits. + +The memory endpoints run GC after fixture setup and after all image responses. The Session, admitted image references, and every base64 response remain reachable at the second endpoint. These retained endpoints include setup-owned objects, and peak RSS is cumulative from process start, including fixture generation. They do not measure image-request memory in isolation or impose a retained-memory budget. diff --git a/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.zh.md b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.zh.md new file mode 100644 index 0000000000..0fe69e71a0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 图片读取使用附件存储访问权限 + +Status: implemented + +[English](2026-10-23-store-scoped-image-reads.md) | 中文 + +## Problem + +历史图片加载会为每个未缓存的附件读取请求中的 Session,并搜索其事件内容。搜索用于确认 Session 引用了图片;它不负责定位字节,存储路径已经能由内容寻址 ID 推导。每次读图因此增加了与历史长度成正比的工作。用户反馈的加载延迟还涉及上传;这项发现本身不能确定全部延迟的来源。 + +## Decision + +Session 关联不构成图片访问限制。连接身份认证和 Gateway 权限检查允许调用 `session/attachment` 后,任何调用者都可以通过完整的 `ImageAttachmentRef` 读取同一附件存储中的任意图片,包括仅被其他 Session 引用或未被任何 Session 引用的对象。请求保留 `sessionId`,供现有 Session 资源权限检查使用。该检查通过后,处理函数不要求该 Session 存在,也不会仅因其不存在而返回 `session/not-found`。所有身份使用相同的读图路径。 + +Client 已经持有消息中的完整图片引用,将其作为 `attachment` 与 `sessionId` 一起发送。Session Controller 直接调用已有的 `AttachmentStore.readImage(ref)`。本地提供方校验规范 ID,在自己的对象目录内推导路径,校验 SHA-256,并把实际格式、字节数、宽度和高度与传入引用比对。对象缺失、不可读、损坏或与引用不符时,操作失败。取得引用无需查询 Session 历史。 + +响应保留传入引用。可选的 `name` 和 `originalDimensions` 是调用方提交的显示信息,不根据存储对象校验。Client 现有的历史图片缓存继续按 Session 管理结果和对象 URL;Host 不增加图片字节缓存。 + +该规则缩小了 [Session observation](2026-08-25-session-observations-and-projection-owned-client-state.zh.md) 与[历史传输激活策略](2026-08-18-session-history-and-event-transport.zh.md)涵盖的读取范围。[工作区文件读取](2026-09-05-workspace-files-service.zh.md)保留自己的文件系统授权。 + +## Alternatives considered + +**保留 Session 到图片的 Map 或 projection。** 索引可以消除重复扫描,但保留了产品不需要的访问限制,并增加派生、生命周期和失效处理。 + +**签发图片签名票据。** 票据为同一项不需要的 Session 关联限制增加签发和验证状态。 + +**只优化 owner 的读取。** 这会让不同身份使用不同的图片访问规则,并让获准调用的非 owner 继续承担扫描成本,没有相应产品需求。 + +## Consequences + +调用方知道有效图片引用,且有权为请求中的 Session 资源调用该接口,就可以跨 Session 读取对象。本地 ID 是规范化字节的 SHA-256。对于准入时原样保留的已知 PNG、JPEG 或 WebP 字节,调用方可以计算 ID,取得格式、字节数和尺寸,并通过获准的读取请求探测对象是否存在。这些必需的引用字段不一定来自 Session 日志。 + +图片隔离需要独立 Host 实例及各自的 `DSH_HOME` 目录。随附组合在每个 Host 中使用一个附件存储,不会按 Session 选择存储。身份认证、Gateway 操作与资源检查、固定存储路径、摘要校验和明确失败仍是必需的。 + +图片读取不再依赖历史长度或 Session 物化。文件读取、完整性校验、编码和传输仍有耗时;本次改动不说明上传或模型推理延迟已得到改善。已有 Session 数据和 fork 引用无需迁移。 + +## Verification + +附件读取不得查询或遍历 Session 历史。验证必须区分获准的跨 Session 图片读取与被 Gateway 拒绝的调用,并覆盖正常引用、无效 ID、固有元数据不符、可选显示字段原样保留、缺失和损坏的对象、旧历史页以及 fork 继承图片。性能证据必须区分历史扫描、存储操作和传输,不能把用户反馈的全部延迟归因于扫描。 + +诊断脚本是 [`image-read.perf.ts`](../../../../packages/api/session-controller/tests/image-read.perf.ts),通过配套的 [tsdown 配置](../../../../packages/api/session-controller/tests/image-read.perf.config.ts)编译。基线 worker 使用改动前的控制器与 `{ sessionId, attachmentId: ref.attachmentId }`;候选 worker 使用当前控制器与 `{ sessionId, attachment: ref }`。两者使用相同图片 fixture、工作量和输出校验,并通过同一套已构建的 Local store 执行 `readImage(ref)` 校验。在候选检出目录的根目录编译并运行: + +```sh +pnpm exec tsdown --config-loader native --config packages/api/session-controller/tests/image-read.perf.config.ts +(cd packages/api/session-controller && node --expose-gc .dsh-build/image-read/image-read.perf.js --expect-no-history) +``` + +运行单独编译的基线 worker 时不带 `--expect-no-history`;诊断请求必须匹配基线控制器仅接收 ID 的请求字段。基线负对照加上 `--sample typical --expect-no-history`,该实现会查询和遍历历史,因此负对照必须失败。父进程为每种工作量运行五个新子进程,每个子进程使用已加载的 Session,并对每张图片执行第一次未缓存读取。图片是确定性合成的 1024 × 768 PNG;两种工作量分别为 120 轮、960 个事件、8 张图片,以及 2,000 轮、16,000 个事件、32 张图片。fixture 写入可能已经预热操作系统的文件缓存。 + +`controllerMs` 覆盖从直接调用控制器到返回 base64 的时间。`storeMs` 包含文件 I/O、SHA-256 和文件头校验;`snapshotMs` 单独测量取得历史快照的时间。`scanAndDispatchMs` 是进入存储前的时间减去 `snapshotMs`,因此也包含分派开销,不是独立的扫描计时。JSON 序列化和本机回环 HTTP 传输使用保留的响应单独测量。`requestJsonBytes` 与 `idOnlyRequestJsonBytes` 比较序列化后的请求长度,不测量请求传输时间。冷 Session 恢复、Gateway 处理、浏览器渲染、远端网络延迟、上传和模型推理均不在测量范围内。PR #7021 记录 worker 修订,以及在这些限制下测得的修改前后结果。 + +内存端点在 fixture 准备完成后,以及所有图片响应完成后执行 GC。第二个端点仍保留 Session、已准入的图片引用和每个 base64 响应。这两个保留内存端点包含准备阶段拥有的对象,峰值 RSS 则从进程启动累计,包含 fixture 生成。它们不单独测量图片请求内存,也不规定保留内存预算。 diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml new file mode 100644 index 0000000000..dad8343dcf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for 2026-10-21-floating-window-edge-recovery.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md +/agent-note-spring-based-edge-recovery-for-floating-dock-panels: + en: 73438e22c1bef8d3 + zh: 5f817eb06259f8ef +/agent-note-spring-based-edge-recovery-for-floating-dock-panels/problem: + en: c3b007f5cce80d8b + zh: c08b07f7e71eaa5f +/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision: + en: f88f165ca80a57b8 + zh: 98559f2239b03445 +/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/legal-region-and-displayed-position: + en: 6dea90dc406eacf5 + zh: 6ecae0fdad78c855 +/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/drag-with-resistance: + en: 5214241925b52bc3 + zh: bbd66dc72c03f960 +/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/release-and-spring: + en: ba2b5a3b4923d585 + zh: 99544462a464ac50 +/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/reduced-motion: + en: 7e29bf1c45b5386d + zh: c913a2ce6d1722d1 +/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/change-scope: + en: 6459103379aefe41 + zh: 501dee08a977b6a0 +/agent-note-spring-based-edge-recovery-for-floating-dock-panels/testing: + en: da3b2b6781aa99f3 + zh: 37e49fa22f72fb84 +/agent-note-spring-based-edge-recovery-for-floating-dock-panels/alternatives-considered: + en: 3d901f0f5e270e29 + zh: c1fb89b567ea3c70 +/agent-note-spring-based-edge-recovery-for-floating-dock-panels/consequences: + en: 360bd69c918f23fe + zh: 48642fd59ad3279b diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md new file mode 100644 index 0000000000..a4e9ca4e25 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md @@ -0,0 +1,106 @@ +# Agent Note: Spring-based edge recovery for floating dock panels + +Status: implemented + +English | [中文](2026-10-21-floating-window-edge-recovery.zh.md) + +## Problem + +The dock kit's floating panels are the client's floating windows. `FloatLayer` and the retained `DockLayout` renderer draw them in viewport coordinates through one shared gesture hook, `useFloatGestures`, and the right Sidebar embeds them, so the same panels run in the Web client and inside the Desktop window. Their drag followed the pointer one to one with only a top floor (`--dsh-dockkit-float-top`, which keeps the header below the Windows caption). A panel could therefore be dragged past the left, right or bottom edge until its header was out of reach; a viewport that shrank left a panel outside it; and the creation cascade could place a new panel outside a small viewport. The required behavior is that a panel pushed past an edge visibly resists, and on release is pulled back inside with spring physics instead of being stopped by a wall or left outside. + +The reference implementation examined, Claude Desktop, has no portable edge-recovery rule: its Electron fallback corrects an off-screen window once, without animation, on the right and bottom edges only. The behavior had to be designed, not ported. + +The change keeps these obligations: a panel header is always reachable; one gesture still records at most one history entry; reduced motion is honored; the persisted Sidebar layout format and every embedder stay unchanged; and the motion is deterministic enough to test without a real browser clock. + +## Decision + +The behavior lives entirely in `packages/client/ui-dockkit`, inside `useFloatGestures` and the pure engine helpers it calls. Both float renderers take their rectangles from that hook through `rectOf`, so Web and Desktop share the behavior, and no embedder, shell or persisted format changed. The rule extends the floating panels of [right Sidebar docking](2026-09-04-right-sidebar-docking-infrastructure.md) and the fixed-position floating frames of the [retained tab layout](../architecture/2026-09-20-sidebar-retained-tab-layout.md); it supersedes neither note. + +### Legal region and displayed position + +The legal region of a panel is the viewport (`window.innerWidth` by `window.innerHeight`) with the existing top floor: the Windows caption height plus 20 px when `data-windows-titlebar` is set (20 px in native fullscreen), otherwise 0. `floatBounds` reads only these values and the root's attributes, so starting a gesture forces no layout. `legalFloatRect` projects a rectangle into the region per axis; when a panel is wider or taller than the region it is drawn at the region's size on that axis, so its header controls and its resize corner stay on screen; the stored size is unchanged. + +A panel draws at its stored rectangle projected into the viewport. The stored rectangle is never rewritten for the viewport: a shrinking window pushes the panel in without recording an operation, and a growing window returns it toward its stored position. The hook re-reads the bounds at every press and on every window `resize` event, and a gesture in progress uses the new bounds. Persisted layouts that hold off-screen rectangles display inside the viewport without migration. + +At rest a panel publishes its rectangle to the `.float` rule as CSS variables through `floatStyle`, and the rule clamps its position and size into the viewport (`100vw` by `100dvh`, which follows `window.innerHeight` as the hook does) and below the caption floor once more; an oversized panel takes the viewport's size there as it does in `legalFloatRect`. A bounds reading the hook has not refreshed, such as one taken before a fullscreen change settles, therefore still leaves the panel inside. A gesture or a return writes its position and size inline, outside the clamp. + +### Drag with resistance + +A move computes the desired rectangle from the gesture's start rectangle and the pointer travel, then `resistedRect` splits it per axis into the legal projection and the overflow `o` beyond it and draws it at `legal + sign(o) * K * |o| / (K + |o|)` with `K = FLOAT_RESISTANCE = 120` px. The mapping applies on all four edges, including above the top floor, is continuous, has slope 1 at the edge, and approaches `K`: + +| pointer overflow (px) | panel displacement past the edge (px) | share of the overflow | +|---|---|---| +| 10 | 9.2 | 92.3% | +| 40 | 30.0 | 75.0% | +| 120 | 60.0 | 50.0% | +| 400 | 92.3 | 23.1% | + +A resize clamps the bottom-right corner to the region through `boundedResize` and keeps `FLOAT_MIN_SIZE`; resizing has no resistance, because a corner that lags the pointer reads as a broken handle. + +### Release and spring + +A release records the legal rectangle: `moveFloat` or `resizeFloat` when it differs from the legal rectangle at the press, otherwise only the raise a click records. A click on a panel drawn projected therefore keeps its stored rectangle. A move or resize therefore records only legal rectangles, and undo restores the pre-gesture position in one step. Panel creation (the drop rectangle of `floatTab` and the cascade) can still store a rectangle outside the viewport; the projection draws it inside. When the panel was drawn outside the legal region, the hook keeps a preview and springs it from where it was drawn to the recorded rectangle, then clears the preview. A release inside the region animates nothing. + +The spring in `engine/spring.ts` is underdamped, because its bounce goes inward and is always legal: response 0.32 s and damping ratio 0.72. An overflowing axis starts with the velocity of the drawn panel over the last 100 ms of the drag, so the speed is continuous across the release; it is capped at `SPRING_MAX_VELOCITY` (1500 px/s), and `launchVelocity` caps its outward part at `omega0 * (K - d)` for a drawn overflow `d`. A fling past the edge travels further before it turns back, and because the spring carries an offset `d` with speed `v` at most `d + 0.46 * v / omega0` from its target, the whole return stays within `K` of the edge. An axis that did not overflow keeps still. Per axis the update is: + +``` +a = -omega0^2 * (x - target) - 2 * zeta * omega0 * v +v = v + a * h +x = x + v * h +``` + +`stepSpring` integrates in equal substeps of at most `h = 1/480 s`, so the motion does not depend on the frame rate. One `requestAnimationFrame` loop in the hook clamps a frame to 64 ms, ends at the target when a frame gap exceeds 250 ms or 900 ms have passed, and otherwise stops when each axis is within 1 px of the target and slower than 60 px/s. From rest, a 200 px overflow settles in 367 ms with an inward overshoot within one percentage point of the analytic bound `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%`. The integrator is pure and takes `dt`, so the engine keeps its rule of reading no clock. + +A press on the grip or the corner during a return cancels it and picks the panel up where it is drawn. A move does so through `unresistedRect`: it inverts the resistance, so zero pointer travel draws the same rectangle. A resize keeps the drawn origin and caps only the corner by the viewport; its release records the legal rectangle and springs the origin inside, as does a resize whose viewport shrank past its origin. Either way a release in place resumes the return without recording a second operation. One hook runs at most one return. A window resize ends a running return at its recorded rectangle, a press on another panel ends it at its target, a change of the panel's record that does not project to the target (an undo, a dock, a close) ends it at the new record on the next frame, and unmounting cancels the pending frame. + +### Reduced motion + +When `matchMedia('(prefers-reduced-motion: reduce)')` matches, a move clamps to the legal region with no resistance, and release records the legal rectangle with no animation. Environments without `matchMedia` report no preference. The rule only removes motion; it never removes the legality correction. + +### Change scope + +The change touches one package, `@deepseek-ai/dsh-client-ui-dockkit`, and leaves its exports and `DockIntents` unchanged; the `TabRenderer` contract now states when the kit reuses a body. + +| File | Change | +|---|---| +| `packages/client/ui-dockkit/src/engine/geometry.ts` | `FloatBounds`, `FLOAT_RESISTANCE`, `legalFloatRect`, `resistedRect`, `unresistedRect`, `boundedResize` | +| `packages/client/ui-dockkit/src/engine/spring.ts` | the pure spring integrator, its rest predicate `springSettled`, `launchVelocity` and its timing limits | +| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`: viewport projection, resistance, release spring, drawn-velocity sampling, reduced motion, frame loop and cancellation; `rectOf` and `floatStyle` for both renderers | +| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | draws the hook's displayed rectangle through `floatStyle` | +| `packages/client/ui-dockkit/src/components/TabBody.tsx` | the memoized body every layout (`FloatLayer`, `TabLayout`, `TabPanel`) draws | +| `packages/client/ui-dockkit/src/contract/adapter.ts` | when `TabRenderer` is called again | +| `packages/client/ui-dockkit/src/components/dockkit.module.css` | the resting clamp of position and size on `.float` | +| `packages/client/ui-dockkit/README.md` pair | the edge rule beside the float renderer description | +| `apps/web/tests/sidebar-right.e2e.ts` | a browser case for the resistance and the return | + +`ui-sidebar-right` and its persisted layout schema, the `apps/web` source, `apps/desktop`, the shared primitives and the theme are untouched. No dependency was added. + +## Testing + +`tests/float-bounds.client.spec.ts` covers the projection (inside, each edge, corner, the top floor, an oversized panel), the resistance (the table values, slope 1 at the edge, monotone growth bounded by `K`, independent axes), its inverse and its cap, the bounded resize, and the legal size kept by the resistance and its inverse. `tests/spring.client.spec.ts` covers settling inside the deadline, the overshoot bound, frame-rate independence at 30, 60 and 120 Hz, the effect of release velocity, determinism, and the launch cap keeping every return within `K`. `tests/app-region-styles.client.spec.ts` pins the resting clamp in the stylesheet. + +`tests/float-recovery.client.spec.tsx` renders `FloatLayer` over a live controller with hand-driven frames and clock: the resisted preview past an edge, exactly one legal `moveFloat` per release and the spring to it with an inward overshoot, undo in one step, fling against slow push, a fast fling staying within `K`, a drag above the top floor, no animation for a release inside, a push that ends at the stored position, a press mid-return picking the panel up without a jump, the stall snap, the deadline, a body not re-rendered through a drag and its return but re-rendered for a new renderer, a corner press during a return picking the panel up where it is drawn and a resize from there springing in, a resize whose viewport shrank past its origin recording a legal rectangle, with no animation under reduced motion, the 20 px floor on a Windows root without a published caption height, a return yielding to an undo, a close and a press on another panel, unmount cancellation, both reduced-motion answers, the viewport projection on shrink and growth without any intent, a click on a projected panel recording nothing, a window resize during a gesture bounding its release, a resize ending a return, and an off-screen stored rectangle drawn inside. `tests/tab-layout.client.spec.tsx` adds that a float drag re-renders no docked or floating body until the renderer changes, and `components.client.spec.tsx` the same for a `DockSurface` divider drag. The existing `FloatLayer` and `TabLayout` specs still pass with their position assertions read from the resting variables, including the rule that starting a gesture forces no layout read; every changed source file is fully covered. `apps/web/tests/sidebar-right.e2e.ts` drags a Sidebar float past the right and bottom edges in Chromium, checks that the drawn panel stays within 120 px beyond them, waits for the release to settle flush against both edges, then publishes a resting rectangle far outside and larger than the viewport and checks that the stylesheet alone draws it exactly over the viewport. + +## Alternatives considered + +**Hard clamp at the edge.** Stopping the panel at the viewport edge is the cheapest correct rule, but it feels like hitting a wall and gives no sense of the boundary before contact. It remains as the reduced-motion path. + +**Clamp during the drag and rebound on release.** Clamping the drag at the edge and converting the outward pointer speed into an inward bounce keeps every frame inside the viewport. It hides how far the user pushed, so the bounce has no visible cause; the resistance shows the push and still bounds the overflow at 120 px. + +**Eased tween on release.** A fixed 240-300 ms ease-out from the released position is predictable, but it carries no velocity, so a fling and a slow push return identically, and a press during it either jumps or restarts. The spring keeps velocity continuous and is interruptible. + +**Store the off-screen position and correct it later.** Recording the released rectangle and fixing it on the next render would persist off-screen positions and make undo restore them. Recording the legal rectangle keeps one history entry per gesture with a legal value. + +**Rewrite stored rectangles when the viewport shrinks.** Moving panels through operations on resize would add history entries the user never made and change persisted layouts on every window resize. Projecting at render achieves the visible result without either, at the cost of the elastic return described below. + +**Native always-on-top OS window.** A real Electron floating window could float above other applications, but only on Desktop; the Web client cannot do it at all, and native frameless dragging gives no per-frame control, so the resistance would be impossible and the spring could only start after release. It is a separate product decision with its own window, IPC and platform work, and this decision does not preclude it. + +**Animation library dependency.** A spring library would replace a pure integrator of a few dozen lines and its tests with a new dependency in a statically linked package. The integrator also has to be clock-free for the engine's deterministic tests. + +**A shared primitive in `ui-primitives`.** Placing the hook in the primitives package would widen the change to a second package with no second consumer. It can move there when another floating surface needs it. + +## Consequences + +Floating panels can no longer be lost off screen, in either client, and a push past an edge reads as a pull back rather than a wall. History, persistence and every embedder are unaffected, and the motion is covered by deterministic tests instead of manual checks. + +A drag and the spring update the preview through React state, so the layout re-renders on every frame, for up to 900 ms after release as well. Every layout draws tab bodies through `TabBody`, which is memoized on the renderer and the tab record, so such a frame re-renders only the panel frames and headers (the header still calls `renderTabTitle`), never the tab bodies; writing a transform on the panel element directly is the fallback if the remaining cost measures as a problem. A panel pushed in by a shrinking window returns toward its stored position when the window grows, which a user who expects it to stay where it was pushed may find surprising. Content inside a panel, such as a webview, can observe up to 900 ms of motion after release. The constants are tuned for a mouse or trackpad; touch input works through the same pointer events but remains untuned. diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md new file mode 100644 index 0000000000..6ec92d9326 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md @@ -0,0 +1,106 @@ +# Agent Note: 悬浮停靠面板的弹簧式边界收回 + +Status: implemented + +[English](2026-10-21-floating-window-edge-recovery.md) | 中文 + +## 问题 + +dock kit 的悬浮面板就是客户端里的悬浮窗。`FloatLayer` 与保留式 `DockLayout` 渲染器通过同一个手势 hook `useFloatGestures` 以视口坐标绘制它们,右侧 Sidebar 嵌入了它们,因此同一套面板既运行在 Web 客户端中,也运行在 Desktop 窗口内。它们的拖拽过去一比一跟随指针,只有一个顶部下限(`--dsh-dockkit-float-top`,让标题栏保持在 Windows 标题栏之下)。因此面板可以被拖过左、右或下边界,直到标题栏够不着;视口缩小会把面板留在外面;新建面板的级联位置也可能落在小视口之外。期望行为是:面板被推过边界时可见地受到阻力,松手后以弹簧物理被拉回视口内,而不是像撞墙一样被挡住或被留在外面。 + +所考察的参考实现 Claude Desktop 没有可移植的边界收回规则:它的 Electron 回退只对屏幕外窗口做一次无动画修正,且只修正右边界和下边界。该行为必须被设计,而不是移植。 + +变更维持以下义务:面板标题栏始终可达;一个手势仍然最多记录一条历史;遵循减弱动态效果;Sidebar 的持久化布局格式与所有嵌入方保持不变;运动足够确定,无需真实浏览器时钟即可测试。 + +## 决定 + +该行为完全位于 `packages/client/ui-dockkit` 内,在 `useFloatGestures` 及其调用的纯 engine 辅助函数中实现。两个悬浮渲染器都通过 `rectOf` 从该 hook 取得矩形,因此 Web 与 Desktop 共享该行为,任何嵌入方、壳或持久化格式都没有改变。该规则扩展了 [右侧 Sidebar 停靠](2026-09-04-right-sidebar-docking-infrastructure.zh.md) 的悬浮面板与 [保留式标签布局](../architecture/2026-09-20-sidebar-retained-tab-layout.zh.md) 的固定定位悬浮框,不取代其中任何一篇。 + +### 合法区域与显示位置 + +面板的合法区域是视口(`window.innerWidth` × `window.innerHeight`)加上既有的顶部下限:设置了 `data-windows-titlebar` 时为 Windows 标题栏高度加 20 px(原生全屏时为 20 px),否则为 0。`floatBounds` 只读取这些值和根元素属性,因此开始手势不会触发布局。`legalFloatRect` 逐轴把矩形投影进该区域;面板比区域更宽或更高时,在该轴上按区域尺寸绘制,使标题栏控件和缩放角留在屏幕上;存储的尺寸不变。 + +面板绘制在其存储矩形投影到视口后的位置。存储矩形从不因视口而被改写:窗口缩小时面板被推入且不记录操作,窗口变大时面板朝存储位置回移。hook 在每次按下和每次窗口 `resize` 事件时重新读取区域,进行中的手势使用新区域。包含屏幕外矩形的持久化布局无需迁移即在视口内显示。 + +静止时面板通过 `floatStyle` 把矩形以 CSS 变量发布给 `.float` 规则,该规则再次把它的位置和尺寸钳制在视口(`100vw` × `100dvh`;`dvh` 与 hook 读取的 `window.innerHeight` 一致)内并保持在标题栏下限之下;超尺寸面板在这里同样按视口尺寸绘制,与 `legalFloatRect` 一致。因此即使 hook 读到的区域尚未刷新(例如全屏切换完成前读取的值),面板仍在视口内。手势或回弹把位置和尺寸写在行内样式中,不受该钳制。 + +### 带阻力的拖拽 + +移动时由手势起始矩形和指针位移计算期望矩形,然后 `resistedRect` 逐轴把它拆分为合法投影与超出它的溢出量 `o`,并绘制在 `legal + sign(o) * K * |o| / (K + |o|)`,其中 `K = FLOAT_RESISTANCE = 120` px。该映射作用于全部四条边,包括顶部下限之上,连续、在边界处斜率为 1、渐近于 `K`: + +| 指针溢出(px) | 面板越过边界的位移(px) | 占溢出的比例 | +|---|---|---| +| 10 | 9.2 | 92.3% | +| 40 | 30.0 | 75.0% | +| 120 | 60.0 | 50.0% | +| 400 | 92.3 | 23.1% | + +缩放通过 `boundedResize` 把右下角钳制在区域内并保持 `FLOAT_MIN_SIZE`;缩放不加阻力,因为落后于指针的角看起来像坏掉的手柄。 + +### 松手与弹簧 + +松手时记录合法矩形:与按下时的合法矩形不同时记录 `moveFloat` 或 `resizeFloat`,否则只记录点击会记录的提升。因此单击以投影绘制的面板会保留其存储矩形。因此移动或缩放只记录合法矩形,撤销一步即可恢复手势前的位置。创建面板(`floatTab` 的落点矩形与级联)仍可能存储视口外的矩形,由投影把它绘制在视口内。若面板绘制在合法区域之外,hook 保留预览,用弹簧把它从绘制位置带到记录的矩形,然后清除预览。在区域内松手不播放动画。 + +`engine/spring.ts` 中的弹簧是欠阻尼的,因为它的回弹朝向内侧,始终合法:响应时间 0.32 s、阻尼比 0.72。溢出的轴以拖拽最后 100 ms 内绘制面板的速度起步,因此速度在松手前后连续;该速度上限为 `SPRING_MAX_VELOCITY`(1500 px/s),且对绘制溢出量 `d`,`launchVelocity` 把其向外分量限制在 `omega0 * (K - d)` 以内。甩过边界的面板会先走得更远再折返;由于弹簧从偏移 `d`、速度 `v` 出发时距目标至多 `d + 0.46 * v / omega0`,整条回弹轨迹都不超出边界 `K`。没有溢出的轴保持不动。每轴更新为: + +``` +a = -omega0^2 * (x - target) - 2 * zeta * omega0 * v +v = v + a * h +x = x + v * h +``` + +`stepSpring` 以等长且不超过 `h = 1/480 s` 的子步积分,因此运动与帧率无关。hook 中唯一的 `requestAnimationFrame` 循环把单帧钳制到 64 ms,帧间隔超过 250 ms 或已过 900 ms 时直接停在目标,否则在每轴距目标 1 px 内且慢于 60 px/s 时停止。从静止开始,200 px 溢出在 367 ms 内稳定,向内过冲与解析上限 `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%` 相差不超过一个百分点。积分器是纯函数并接收 `dt`,因此 engine 保持不读取时钟的规则。 + +回弹期间按下抓手或右下角会取消回弹,并从绘制位置接住面板。移动通过 `unresistedRect` 做到这一点:它反演阻力,因此零指针位移绘制同一矩形。缩放保留绘制时的原点,只用视口约束右下角;松手时记录合法矩形并用弹簧把原点收回视口,手势中视口缩到原点以内的缩放也是如此。两种情况下原地松手都会恢复回弹且不记录第二次操作。一个 hook 最多运行一个回弹。窗口缩放会让进行中的回弹停在记录的矩形;按下另一个面板会让它停在目标;面板记录的变化若不再投影到目标(撤销、停靠、关闭),会在下一帧让它停在新记录;卸载会取消待处理的帧。 + +### 减弱动态效果 + +`matchMedia('(prefers-reduced-motion: reduce)')` 匹配时,移动钳制在合法区域内且无阻力,松手时记录合法矩形且不播放动画。没有 `matchMedia` 的环境视为无偏好。该规则只移除运动,从不移除合法性修正。 + +### 改动范围 + +变更只涉及一个包 `@deepseek-ai/dsh-client-ui-dockkit`,其导出与 `DockIntents` 保持不变;`TabRenderer` 的约定写明了套件何时复用标签页内容。 + +| 文件 | 变更 | +|---|---| +| `packages/client/ui-dockkit/src/engine/geometry.ts` | `FloatBounds`、`FLOAT_RESISTANCE`、`legalFloatRect`、`resistedRect`、`unresistedRect`、`boundedResize` | +| `packages/client/ui-dockkit/src/engine/spring.ts` | 纯弹簧积分器、静止判定函数 `springSettled`、`launchVelocity` 与时间上限 | +| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`:视口投影、阻力、松手弹簧、绘制速度采样、减弱动态效果、帧循环与取消;供两个渲染器使用的 `rectOf` 与 `floatStyle` | +| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | 通过 `floatStyle` 绘制 hook 给出的显示矩形 | +| `packages/client/ui-dockkit/src/components/TabBody.tsx` | 各布局(`FloatLayer`、`TabLayout`、`TabPanel`)共用的 memo 化标签页内容 | +| `packages/client/ui-dockkit/src/contract/adapter.ts` | `TabRenderer` 何时被再次调用 | +| `packages/client/ui-dockkit/src/components/dockkit.module.css` | `.float` 上位置与尺寸的静止钳制 | +| `packages/client/ui-dockkit/README.md` 对 | 在悬浮渲染器说明旁补充边界规则 | +| `apps/web/tests/sidebar-right.e2e.ts` | 覆盖阻力与回弹的浏览器用例 | + +`ui-sidebar-right` 及其持久化布局 schema、`apps/web` 源码、`apps/desktop`、共享基础组件与主题均未改动。没有新增依赖。 + +## 测试 + +`tests/float-bounds.client.spec.ts` 覆盖投影(内部、每条边、角、顶部下限、超尺寸面板)、阻力(表中数值、边界处斜率为 1、以 `K` 为上界的单调增长、各轴独立)、其反演与上限,受限缩放,以及阻力与其反演都保持合法尺寸。`tests/spring.client.spec.ts` 覆盖在截止时间内稳定、过冲上限、30/60/120 Hz 下与帧率无关、松手速度的作用、确定性,以及启动速度上限让每条回弹都不超出 `K`。`tests/app-region-styles.client.spec.ts` 固定样式表中的静止钳制。 + +`tests/float-recovery.client.spec.tsx` 在活的控制器上渲染 `FloatLayer`,帧与时钟由测试手动推进:越过边界时带阻力的预览、每次松手恰好一次合法的 `moveFloat` 以及带向内过冲的弹回、一步撤销、甩出与慢推的对比、快速甩出不超出 `K`、拖过顶部下限、区域内松手不播放动画、推到存储位置的手势、回弹中按下接住面板且不跳变、停顿直接跳转、截止时间、拖拽与回弹期间不重渲染标签页内容而换新渲染函数后重渲染、回弹中按下右下角从绘制位置接住面板、从那里缩放后弹回、手势中视口缩到缩放原点以内时记录合法矩形且减弱动态效果下不播放动画、未发布标题栏高度的 Windows 根元素仍保持 20 px 下限、回弹让位于撤销、关闭与按下另一个面板、卸载取消、两种减弱动态效果答复、视口缩小与变大时的投影且不调用任何 intent、单击投影面板不记录任何操作、手势中窗口缩放约束其松手结果、缩放结束回弹,以及屏幕外存储矩形在视口内绘制。`tests/tab-layout.client.spec.tsx` 新增:悬浮面板拖拽期间停靠与悬浮的标签页内容都不重渲染,直到渲染函数改变;`components.client.spec.tsx` 对 `DockSurface` 拖动分隔条做同样的检查。既有的 `FloatLayer` 与 `TabLayout` 测试在位置断言改为读取静止变量后通过,包括开始手势不做布局读取这一规则;每个改动的源文件都被完全覆盖。`apps/web/tests/sidebar-right.e2e.ts` 在 Chromium 中把 Sidebar 悬浮面板拖过右边界和下边界,检查绘制的面板越过两边均不超过 120 px,等待松手后面板贴合两条边静止,再发布一个远在视口外且大于视口的静止矩形,检查仅靠样式表就把它恰好绘制成覆盖整个视口。 + +## 考虑过的替代方案 + +**在边界处硬钳制。** 在视口边界停住面板是成本最低的正确规则,但手感像撞墙,接触之前也感觉不到边界。它保留为减弱动态效果的路径。 + +**拖拽中钳制、松手时回弹。** 在边界处钳制拖拽,并把向外的指针速度转换为向内的回弹,能让每一帧都留在视口内。但它隐藏了用户推了多远,回弹因此没有可见的起因;阻力显示了推动,同时把溢出限制在 120 px 内。 + +**松手后的缓动补间。** 从松手位置开始的固定 240–300 ms 缓出补间可预测,但不携带速度,因此甩出与慢推的返回完全相同,补间中按下要么跳变要么重启。弹簧保持速度连续且可打断。 + +**存储屏幕外位置并在之后修正。** 记录松手矩形、在下一次渲染时修正,会持久化屏幕外位置,并让撤销恢复它们。记录合法矩形则保持每个手势一条历史且值合法。 + +**视口缩小时改写存储矩形。** 在缩放时通过操作移动面板会增加用户从未做过的历史条目,并在每次窗口缩放时改变持久化布局。渲染时投影无需二者即可得到可见结果,代价是下文描述的弹性返回。 + +**原生始终置顶 OS 窗口。** 真正的 Electron 悬浮窗可以浮在其他应用之上,但只在 Desktop 上可行;Web 客户端完全无法做到,而且原生无边框拖拽不提供逐帧控制,因此阻力无法实现,弹簧只能在松手后启动。它是一个独立的产品决策,有自己的窗口、IPC 与平台工作,本决定不排斥它。 + +**动画库依赖。** 弹簧库会用一个静态链接包里的新依赖替换几十行的纯积分器及其测试。积分器还必须不依赖时钟,才能用于 engine 的确定性测试。 + +**放进 `ui-primitives` 的共享基础组件。** 把 hook 放进基础组件包会把改动扩大到第二个包,而没有第二个使用方。等另一个悬浮界面需要它时再迁移。 + +## 后果 + +悬浮面板在两个客户端中都不会再丢失到屏幕外,推过边界读起来是被拉回而不是撞墙。历史、持久化与所有嵌入方不受影响,运动由确定性测试而非手动检查覆盖。 + +拖拽与弹簧都通过 React state 更新预览,因此布局每帧都会重渲染,松手后最多还会持续 900 ms。各布局都通过 `TabBody` 绘制标签页内容,它以渲染函数和标签记录为键做了 memo,因此这样的一帧只重渲染面板框与标题栏(标题栏仍会调用 `renderTabTitle`),从不重渲染标签页内容;如果剩余成本经测量构成问题,退路是直接在面板元素上写 transform。被缩小窗口推入的面板会在窗口变大时朝存储位置回移,期望它留在被推到位置的用户可能会感到意外。面板内的内容(例如 webview)在松手后可能观察到最多 900 ms 的运动。常量针对鼠标或触控板调优;触控输入经由同样的 pointer 事件可用,但仍未调优。 diff --git a/.gitattributes b/.gitattributes index e878c9db19..2bead994d2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,6 @@ docs/persistence-schema.json linguist-generated=true docs/persistence-changes/*.schema.json linguist-generated=true docs/persistence-changes/releases/*.schema.json linguist-generated=true docs/persistence-changes/historical-formats/*.schema.json linguist-generated=true + +# Unified patch blank context lines require their leading space. +patches/*.patch whitespace=-blank-at-eol diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 35f0103169..965145639f 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -4,4 +4,6 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. +The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`. + The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. diff --git a/.github/workflows/pr-check-after-approve.yml b/.github/workflows/pr-check-after-approve.yml new file mode 100644 index 0000000000..1255ae9373 --- /dev/null +++ b/.github/workflows/pr-check-after-approve.yml @@ -0,0 +1,94 @@ +name: Policy +on: + pull_request_target: + branches: [master] + types: [opened, synchronize, reopened, ready_for_review] + pull_request_review: + types: [submitted, dismissed] +permissions: + contents: read + pull-requests: read +concurrency: + group: pr-check-after-approve-${{ github.event.pull_request.number }} + cancel-in-progress: true +jobs: + approve: + name: Approve Check + # Each check runs only while its repository variable is neither empty nor `off`; otherwise the job is skipped. + if: ${{ vars.MERGE_POLICY_HEAD_DIFF != '' && vars.MERGE_POLICY_HEAD_DIFF != 'off' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: read + steps: + # The head checkout is only the Git repository the check fetches into; it needs no files and runs no code. + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + filter: blob:none + sparse-checkout: README.md + sparse-checkout-cone-mode: false + + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + package-manager-cache: false + + - uses: deepseek-harness/deepseek-harness-check@master + env: + CHECK_MODE: ${{ vars.MERGE_POLICY_HEAD_DIFF }} + with: + check: head-diff + base-branch: ${{ github.event.pull_request.base.ref }} + head-sha: ${{ github.event.pull_request.head.sha }} + pr-number: ${{ github.event.pull_request.number }} + pr-author: ${{ github.event.pull_request.user.login }} + + review: + name: Reviewer Evaluate + if: ${{ vars.MERGE_POLICY_REVIEW != '' && vars.MERGE_POLICY_REVIEW != 'off' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: read + # The check posts its verdict as a check run of its own. + checks: write + steps: + # The head checkout is only the Git repository the check fetches into; it needs no files and runs no code. + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + filter: blob:none + sparse-checkout: README.md + sparse-checkout-cone-mode: false + + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + package-manager-cache: false + + # The check app's token makes the review check run its own row instead of joining this workflow's suite. + - id: token + continue-on-error: true + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.DSH_APPROVAL_APP_CLIENT_ID }} + private-key: ${{ secrets.DSH_APPROVAL_APP_PRIVATE_KEY }} + owner: deepseek-harness + repositories: deepseek-harness + permission-checks: write + + - uses: deepseek-harness/deepseek-harness-check@master + env: + CHECK_MODE: ${{ vars.MERGE_POLICY_REVIEW }} + CHECK_TOKEN: ${{ steps.token.outputs.token }} + with: + check: review + base-branch: ${{ github.event.pull_request.base.ref }} + head-sha: ${{ github.event.pull_request.head.sha }} + pr-number: ${{ github.event.pull_request.number }} + pr-author: ${{ github.event.pull_request.user.login }} diff --git a/.github/workflows/pr-check-on-base-branch.yml b/.github/workflows/pr-check-on-base-branch.yml new file mode 100644 index 0000000000..32d4d46165 --- /dev/null +++ b/.github/workflows/pr-check-on-base-branch.yml @@ -0,0 +1,30 @@ +name: Policy +on: + push: + branches: [master] + workflow_dispatch: +permissions: {} +concurrency: + group: pr-check-on-base-branch-${{ github.ref_name }} +jobs: + recheck: + name: Recheck + # Re-runs the head-branch checks, so it follows their variables. + if: ${{ vars.MERGE_POLICY_MASTER_DIFF != '' && vars.MERGE_POLICY_MASTER_DIFF != 'off' || vars.MERGE_POLICY_PRE_MERGE != '' && vars.MERGE_POLICY_PRE_MERGE != 'off' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: write + contents: read + pull-requests: read + steps: + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + package-manager-cache: false + + - uses: deepseek-harness/deepseek-harness-check@master + with: + check: recheck + base-branch: ${{ github.ref_name }} + recheck-label-token: ${{ secrets.DSH_RECHECK_LABEL_TOKEN }} diff --git a/.github/workflows/pr-check-on-head-branch.yml b/.github/workflows/pr-check-on-head-branch.yml new file mode 100644 index 0000000000..cb72293c53 --- /dev/null +++ b/.github/workflows/pr-check-on-head-branch.yml @@ -0,0 +1,84 @@ +name: Policy +on: + pull_request_target: + branches: [master] + types: [opened, synchronize, reopened, ready_for_review, review_requested, auto_merge_enabled, labeled] +permissions: + contents: read +concurrency: + group: pr-check-on-head-branch-${{ github.event.pull_request.number }} + cancel-in-progress: true +jobs: + master: + name: Master Change + # Each check runs only while its repository variable is neither empty nor `off`; otherwise the job is skipped. + if: ${{ vars.MERGE_POLICY_MASTER_DIFF != '' && vars.MERGE_POLICY_MASTER_DIFF != 'off' && !github.event.pull_request.draft }} + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + base_sha: ${{ steps.check.outputs.base_sha }} + since: ${{ steps.check.outputs.since }} + steps: + # The head checkout is only the Git repository the check fetches into; it needs no files and runs no code. + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + filter: blob:none + sparse-checkout: README.md + sparse-checkout-cone-mode: false + + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + package-manager-cache: false + + - id: check + uses: deepseek-harness/deepseek-harness-check@master + env: + CHECK_MODE: ${{ vars.MERGE_POLICY_MASTER_DIFF }} + with: + check: master-diff + base-branch: ${{ github.event.pull_request.base.ref }} + head-sha: ${{ github.event.pull_request.head.sha }} + + pre-merge: + name: Pre-Merge Check + needs: master + if: ${{ vars.MERGE_POLICY_PRE_MERGE != '' && vars.MERGE_POLICY_PRE_MERGE != 'off' && needs.master.result == 'success' }} + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 60 + # This job installs and compiles the pull request: it reads contents only and receives no secret. + permissions: + contents: read + steps: + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + + - name: Merge the checked base-branch commit + env: + BASE_SHA: ${{ needs.master.outputs.base_sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + SINCE: ${{ needs.master.outputs.since }} + run: | + [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ && "$SINCE" =~ ^[0-9]+$ ]] || { echo "::error::the master diff check reported no base commit"; exit 1; } + git fetch --no-tags --no-write-fetch-head --shallow-since="$SINCE" origin "$HEAD_SHA" "$BASE_SHA" + git -c user.name=pr-check -c user.email=pr-check@users.noreply.github.com merge --no-ff --no-edit -m "Merge $BASE_SHA into $HEAD_SHA" "$BASE_SHA" + + - uses: pnpm/action-setup@v5.0.0 + + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + package-manager-cache: false + + - name: Run the pre-merge command + env: + CHECK_MODE: ${{ vars.MERGE_POLICY_PRE_MERGE }} + run: | + if ! bash -euxo pipefail -c 'pnpm install --frozen-lockfile && pnpm exec tsx scripts/compile-referenced-projects.ts host-contracts && pnpm exec tsx scripts/compile-referenced-projects.ts client'; then + if [ "$CHECK_MODE" = block ]; then exit 1; fi + echo "::warning title=Pre-Merge Check::the merged tree does not install or compile; this check does not block" + fi diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 34f5886c04..1295efff31 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -75,6 +75,7 @@ External packages installed for runtime use or embedded in distributed Host or b | [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`acorn`](https://github.com/acornjs/acorn) | MIT | +| [`acorn-walk`](https://github.com/acornjs/acorn) | MIT | | [`ajv`](https://github.com/ajv-validator/ajv) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`big.js`](https://github.com/MikeMcl/big.js) | MIT | diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index 615270aae0..c1f42d812d 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -29,6 +29,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur writeFileSync(join(root, 'package.json'), '{"type":"module"}') writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}') writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), ` + export class StartupError extends Error {} export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({}); @@ -64,7 +65,11 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur const boot = await new Promise<{ packageManager: { command: string; args: string[]; env: Record } }>((resolve, reject) => { - child.once('message', resolve) + child.on('message', (message) => { + if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'booting' && 'packageManager' in message) { + resolve(message as { packageManager: { command: string; args: string[]; env: Record } }) + } + }) child.once('error', reject) child.once('exit', (code) => { reject(new Error(`Host exited before booting: ${String(code)} ${stderr}`)) }) }) diff --git a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts index 9468f874cc..c97e093237 100644 --- a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts @@ -1,4 +1,4 @@ -/** Account catalog diagnostics preserve the shipped ACP profile's JSON-RPC stream. */ +/** Account catalog and MCP resource-listing diagnostics keep the shipped ACP profile's stdout JSON-RPC only. */ import { once } from 'node:events' import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' @@ -6,10 +6,22 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot' import { expect, it } from 'vitest' import { cleanupAcpExampleTest } from './cleanup.ts' +const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) +const binScript = join(repoRoot, 'apps/cli/src/bin.ts') +const tsconfigPath = join(repoRoot, 'tsconfig.json') + +/** Parse every stdout line as a JSON-RPC frame; a diagnostic line fails the parse with its leading text. */ +function jsonRpcFrames(launched: LaunchedAcpTestAgent): Record[] { + const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record) + for (const frame of frames) expect(frame).toMatchObject({ jsonrpc: '2.0' }) + return frames +} + it('keeps account model-listing diagnostics on stderr during API-key session creation', async () => { const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) const token = 'acp-catalog-fixture-token' @@ -100,3 +112,48 @@ it('keeps account model-listing diagnostics on stderr during API-key session cre if (failures.length > 0) throw new AggregateError(failures, 'ACP catalog test cleanup failed') } }) + +it('keeps MCP resource-listing diagnostics off stdout for a tools-only client server', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) + const llm = await startMockLlmServer({ + sequence: ['tool_call_success', 'success'], + toolName: 'list_mcp_resources', + toolArguments: JSON.stringify({ server: 'fixture' }), + }) + let launched: LaunchedAcpTestAgent | undefined + try { + const configPath = join(cwd, 'mcp.patch.yml') + await writeFile(configPath, JSON.stringify([ + { id: 'llm-deepseek', config: { baseURL: llm.baseURL, models: [{ id: 'fixture-api-key-model' }] } }, + { id: 'acp', config: { provider: 'deepseek-official', model: 'fixture-api-key-model' } }, + { id: 'session-persistence-jsonl', config: { root: join(cwd, 'sessions'), compression: 'none' } }, + ]) + '\n') + launched = launchAcpTestAgent({ + agent: { binScript, configPath, profile: 'acp', tsconfigPath }, + cwd, + env: { DEEPSEEK_API_KEY: 'acp-fixture-api-key', DEEPSEEK_BASE_URL: llm.baseURL, DSH_TELEMETRY_DISABLED: '1' }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + // The fixture server advertises only the tools capability. + const mcpServer = join(repoRoot, 'packages/mcp/mcp-client/tests/fixture-server.ts') + const created = await launched.client.newSession({ + cwd, mcpServers: [{ name: 'fixture', command: process.execPath, args: [mcpServer], env: [] }], + }) + await expect(launched.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'list the fixture resources' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(launched.updates).toContainEqual(expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed' })) + await launched.client.closeSession({ sessionId: created.sessionId }) + await launched.close() + + expect(launched.child.exitCode).toBe(0) + expect(llm.requests.map(request => request.behavior)).toEqual(['tool_call_success', 'success']) + expect(jsonRpcFrames(launched).length).toBeGreaterThanOrEqual(5) + } finally { + const cleanup = await Promise.allSettled([cleanupAcpExampleTest(launched, cwd), llm.close()]) + const failures: unknown[] = [] + for (const result of cleanup) if (result.status === 'rejected') failures.push(result.reason) + if (failures.length > 0) throw new AggregateError(failures, 'ACP MCP test cleanup failed') + } +}) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 161391bbb2..ef3bfd7af1 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -25,7 +25,8 @@ "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*", "@deepseek-ai/dsh-skill-office": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", - "@deepseek-ai/dsh-workspace": "workspace:*" + "@deepseek-ai/dsh-workspace": "workspace:*", + "@deepseek-ai/dsh-plugin-manager": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:~", diff --git a/apps/desktop-host/src/fatal-diagnostic.ts b/apps/desktop-host/src/fatal-diagnostic.ts new file mode 100644 index 0000000000..e72533dae0 --- /dev/null +++ b/apps/desktop-host/src/fatal-diagnostic.ts @@ -0,0 +1,59 @@ +/** Bounded Host failure details sent to the Desktop crash reporter. */ + +import { inspect } from 'node:util' +import { StartupError } from '@deepseek-ai/dsh-app-boot' + +/** Maximum characters carried by one fatal diagnostic, including its truncation notice. */ +export const MAX_FATAL_DIAGNOSTIC_CHARS = 64 * 1024 + +/** Original-error budget when startup metadata needs space in the same report. */ +const MAX_ORIGINAL_ERROR_CHARS = 8 * 1024 + +/** Startup entry details leave room for composition and provider observations. */ +const MAX_STARTUP_ENTRY_CHARS = 8 * 1024 + +/** + * Render the original error and explicitly selected startup metadata. + * Selected metadata excludes configuration values and arbitrary startup logger arguments. + * @param error - Failure received by the Host entry point. + * @returns Bounded text with an explicit notice when the report is shortened. + */ +export function formatFatalDiagnostic(error: unknown): string { + const original = inspect(error, { depth: 4, maxArrayLength: 50 }) + const sections: string[] = [] + const seen = new Set() + function visit(value: unknown): void { + if (!(value instanceof Error) || seen.has(value)) return + seen.add(value) + if (value instanceof StartupError) { + if (value.startup !== undefined) { + sections.push(`Configuration file: ${value.startup.configurationPath}`) + } + const entries = inspect(value.entries, { depth: 6, maxArrayLength: null, maxStringLength: 4096 }) + sections.push('--- startup entries ---', entries.length <= MAX_STARTUP_ENTRY_CHARS ? entries + : `${entries.slice(0, MAX_STARTUP_ENTRY_CHARS).replace(/[\uD800-\uDBFF]$/u, '')}\n… (startup entries truncated at ${MAX_STARTUP_ENTRY_CHARS} characters)`) + if (value.composition !== undefined) { + const { plugins, services, omittedPlugins, omittedServices } = value.composition + const sources = [...new Set(plugins.map(plugin => plugin.source))] + sections.push('--- startup composition ---', + 'Provider lists contain observed registrations only; an empty list means the provider is unknown.', + 'Source identifies the Loader tree, not necessarily the overriding patch.', + `Omitted plugin rows: ${omittedPlugins}; omitted missing-service observations: ${omittedServices}.`, + '--- missing services ---', ...services.map(service => JSON.stringify(service)), + '--- plugin sources (sourceIndex) ---', ...sources.map((source, index) => JSON.stringify({ index, source })), + '--- plugins (inactive first) ---', ...plugins.map(({ source, ...plugin }) => JSON.stringify({ ...plugin, sourceIndex: sources.indexOf(source) }))) + } else { + sections.push('Startup composition diagnostic unavailable.') + } + } + visit(value.cause) + if (value instanceof AggregateError) value.errors.forEach(visit) + } + visit(error) + sections.unshift(sections.length === 0 || original.length <= MAX_ORIGINAL_ERROR_CHARS ? original + : `${original.slice(0, MAX_ORIGINAL_ERROR_CHARS).replace(/[\uD800-\uDBFF]$/u, '')}\n… (original error truncated at ${MAX_ORIGINAL_ERROR_CHARS} characters)`) + const text = sections.join('\n') + if (text.length <= MAX_FATAL_DIAGNOSTIC_CHARS) return text + const notice = `\n… (Host diagnostic truncated at ${MAX_FATAL_DIAGNOSTIC_CHARS} characters)` + return text.slice(0, MAX_FATAL_DIAGNOSTIC_CHARS - notice.length).replace(/[\uD800-\uDBFF]$/u, '') + notice +} diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 3109537b63..31bbbffe15 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -1,7 +1,9 @@ /** Launch the Desktop profile through the Web application and report its URL to Electron. */ import { delimiter, join } from 'node:path' -import { inspect } from 'node:util' +import { randomUUID } from 'node:crypto' +import type { PluginOperationRunId } from '@deepseek-ai/dsh-plugin-manager/diagnostic' +import { formatFatalDiagnostic } from './fatal-diagnostic.ts' import { loadLayeredEnv, loadProfileDirectory, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot' import { runProfile } from '@deepseek-ai/dsh/profile-boot' import type {} from '@deepseek-ai/dsh-client-connection' @@ -15,8 +17,11 @@ import { installDesktopUpdateTaskControl } from './update-tasks.ts' import { installDesktopQuitInspection } from './quit-inspection.ts' import { installPlatformSessionPublisher } from './platform-session.ts' import { installOfficeEngineResolution } from './office-engine.ts' +import { installOperationDiagnostics } from './operation-diagnostics.ts' async function main(): Promise { + const runId = randomUUID() as PluginOperationRunId + if (process.connected) process.send?.({ type: 'starting', runId }, (error) => { if (error !== null) console.error(error) }) const runtimeDir = process.argv[2] as string const projectDir = process.argv[3] as string installOfficeEngineResolution(runtimeDir) @@ -123,6 +128,7 @@ async function main(): Promise { }) process.once('disconnect', () => { void stop() }) const { ctx } = await application + installOperationDiagnostics(ctx, record => send({ type: 'plugin-operation', record }), 32, runId) control.updateTasks = installDesktopUpdateTaskControl(ctx) control.quitInspection = installDesktopQuitInspection(ctx) await ctx.plugin(desktopOffice, { @@ -137,16 +143,13 @@ async function main(): Promise { if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) }) } -/** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */ -const MAX_FATAL_DIAGNOSTIC_CHARS = 64 * 1024 - if (import.meta.main) { main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error) // The shell receives the complete inspected error here, not through stderr: // stderr bytes and this IPC message race, and the shell reports the first // failure it sees. - const diagnostic = inspect(error, { depth: 4, maxArrayLength: 50 }).slice(0, MAX_FATAL_DIAGNOSTIC_CHARS) + const diagnostic = formatFatalDiagnostic(error) if (process.connected) process.send?.({ type: 'fatal', message, diagnostic }, (error) => { if (error !== null) console.error(error) }) console.error(error) process.exitCode = 1 diff --git a/apps/desktop-host/src/operation-diagnostics.ts b/apps/desktop-host/src/operation-diagnostics.ts new file mode 100644 index 0000000000..3d29e97bc0 --- /dev/null +++ b/apps/desktop-host/src/operation-diagnostics.ts @@ -0,0 +1,34 @@ +/** Bounded delivery of management facts to the shell which owns durable diagnostics. */ +import type { Context } from '@deepseek-ai/cordis' +import { parsePluginOperationRecord, type PluginOperationRecord, type PluginOperationRunId } from '@deepseek-ai/dsh-plugin-manager/diagnostic' + +/** + * Observe manager operations after Host startup, with bounded concurrent IPC deliveries. + * @param ctx - Context owning the subscription and its pending deliveries. + * @param send - IPC transport promise which settles when delivery succeeds or fails. + * @param maxPending - Maximum number of outstanding deliveries; excess events are counted and omitted. + * @param runId - Host lifetime identity announced before profile startup. + */ +export function installOperationDiagnostics( + ctx: Context, send: (record: PluginOperationRecord) => Promise, maxPending: number, runId: PluginOperationRunId, +): void { + if (!Number.isSafeInteger(maxPending) || maxPending < 1) throw new RangeError('Invalid diagnostic delivery budget') + const pending = new Set>() + let dropped = 0 + let closed = false + ctx.on('plugin-manager/operation', (operation) => { + if (closed || pending.size >= maxPending) { dropped++; return } + const record = parsePluginOperationRecord({ runId, operation, transportDropped: dropped }) + if (record === undefined) { dropped++; return } + const delivery = Promise.resolve().then(async () => { + try { await send(record) } + catch (_error) { dropped++ /* IPC failure does not change the management operation. */ } + }) + pending.add(delivery) + void delivery.then(() => { pending.delete(delivery) }) + }) + ctx.effect(() => async () => { + closed = true + await Promise.allSettled(pending) + }) +} diff --git a/apps/desktop-host/tests/fatal-diagnostic.spec.ts b/apps/desktop-host/tests/fatal-diagnostic.spec.ts new file mode 100644 index 0000000000..1f41358300 --- /dev/null +++ b/apps/desktop-host/tests/fatal-diagnostic.spec.ts @@ -0,0 +1,105 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished } from 'vitest' +import { boot, StartupError } from '@deepseek-ai/dsh-app-boot' +import { formatFatalDiagnostic, MAX_FATAL_DIAGNOSTIC_CHARS } from '../src/fatal-diagnostic.ts' +import { renderCrashReport } from '../../desktop/src/crash-report.ts' + +it.each([ + ['import', 'throw new Error("qualification-import-failure")'], + ['initialize', 'export function apply() { throw new Error("qualification-initialize-failure", { cause: new Error("qualification-root-cause") }) }'], +])('retains real plugin %s failure evidence in the Desktop report after Loader disposal', async (stage, source) => { + const directory = await mkdtemp(join(tmpdir(), 'desktop-plugin-failure-')) + onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) }) + await writeFile(join(directory, 'failure.mjs'), source + '\n') + const config = join(directory, 'cordis.yml') + await writeFile(config, '- id: agent-loop\n name: ./failure.mjs\n') + const failure: unknown = await boot('diagnostic-test', config).then(async (ctx) => { + await ctx.fiber.dispose() + throw new Error('Expected fixture plugin to fail startup') + }, (error: unknown) => error) + expect(failure).toBeInstanceOf(StartupError) + const diagnostic = formatFatalDiagnostic(failure) + const report = renderCrashReport({ source: 'host', phase: 'startup', error: failure, + hostDiagnostic: diagnostic, rendererConsole: [], operationHistoryPlanned: true, + time: new Date('2026-10-19T00:00:00Z'), + app: { name: 'qualification', version: 'test', platform: 'win32', arch: 'x64', electron: 'test', node: process.version, locale: 'zh-CN' }, + }) + expect(report).toContain(`qualification-${stage}-failure`) + expect(report).toContain('./failure.mjs') + expect(report).toContain('--- startup composition ---') + if (stage === 'initialize') expect(report).toContain('qualification-root-cause') +}) + +it('includes non-enumerable startup entries without copying arbitrary startup logger arguments', () => { + const error = new StartupError('required plugin did not activate', [{ + id: 'agent-loop', module: '@deepseek-ai/dsh-agent-loop', required: true, fiberState: undefined, + outcome: { kind: 'pending', missing: ['agents', 'sessions'] }, + }]) + error.startup = { configurationPath: '/profile/cordis.yml', messages: [{ + ts: 1, name: 'plugin', type: 'warn', args: ['private-plugin-output'], + }] } + const report = formatFatalDiagnostic(new Error('Host startup failed', { cause: error })) + expect(report).toContain('--- startup entries ---') + expect(report).toContain('@deepseek-ai/dsh-agent-loop') + expect(report).toContain("missing: [ 'agents', 'sessions' ]") + expect(report).toContain('Configuration file: /profile/cordis.yml') + expect(report).not.toContain('private-plugin-output') +}) + +it('marks truncation within the complete IPC budget and preserves Unicode', () => { + const error = new Error('😀'.repeat(MAX_FATAL_DIAGNOSTIC_CHARS)) + const report = formatFatalDiagnostic(error) + expect(report.length).toBeLessThanOrEqual(MAX_FATAL_DIAGNOSTIC_CHARS) + expect(report).toContain('Host diagnostic truncated at 65536 characters') + expect(report.isWellFormed()).toBe(true) +}) + +it('keeps missing-service evidence ahead of a truncated plugin inventory', () => { + const error = new StartupError('failure '.repeat(2000), [{ + id: 'waiting-entry', module: './waiting.mjs', required: true, fiberState: undefined, + outcome: { kind: 'pending', missing: ['agents'] }, + }]) + error.startup = { configurationPath: '/profile/critical-cordis.yml', messages: [] } + error.composition = { + services: [{ consumer: 'agent-loop', service: 'agents', providers: [] }], + plugins: Array.from({ length: 256 }, (_, index) => ({ + id: `plugin-${index}`, module: 'long-package-name'.repeat(30), version: '1.0.0', + state: 'disabled', source: '/profile/cordis.yml', missing: [], + })), + omittedPlugins: 3, omittedServices: 2, + } + const report = formatFatalDiagnostic(error) + expect(report).toContain('original error truncated at 8192 characters') + expect(report).toContain('Configuration file: /profile/critical-cordis.yml') + expect(report).toContain('waiting-entry') + expect(report).toContain('"service":"agents","providers":[]') + expect(report).toContain('Omitted plugin rows: 3; omitted missing-service observations: 2.') + expect(report).toContain('"source":"/profile/cordis.yml"') + expect(report).toContain('Host diagnostic truncated at 65536 characters') + expect(report.length).toBeLessThanOrEqual(MAX_FATAL_DIAGNOSTIC_CHARS) +}) + +it('terminates traversal through cyclic and aggregate causes', () => { + const error = new AggregateError([], 'cycle') + error.errors.push(error) + error.cause = error + expect(formatFatalDiagnostic(error)).toContain('cycle') +}) + +it.each(['many entries', 'long stack'])('retains composition evidence after %s', (scenario) => { + const failure = new Error('startup failure') + failure.stack = 'x'.repeat(70000) + const error = new StartupError('startup failed', scenario === 'long stack' + ? [{ id: 'failed', module: './failed.mjs', required: true, fiberState: undefined, outcome: { kind: 'failed', error: failure } }] + : Array.from({ length: 256 }, (_, index) => ({ id: `pending-${index}`, module: 'module'.repeat(60), required: true, fiberState: undefined, outcome: { kind: 'pending' as const, missing: ['agents'] } }))) + error.startup = { configurationPath: '/profile/cordis.yml', messages: [] } + error.composition = { plugins: [{ id: 'provider', module: 'example', version: '1.0.0', state: 'active', source: '/profile/cordis.yml', missing: [] }], services: [{ consumer: 'consumer', service: 'agents', providers: [] }], omittedPlugins: 0, omittedServices: 0 } + const report = formatFatalDiagnostic(error) + expect(report).toContain('Configuration file: /profile/cordis.yml') + expect(report).toContain('startup entries truncated') + expect(report).toContain('"service":"agents"') + expect(report).toContain('"version":"1.0.0"') + expect(report.length).toBeLessThanOrEqual(65536) +}) diff --git a/apps/desktop-host/tests/operation-diagnostics.spec.ts b/apps/desktop-host/tests/operation-diagnostics.spec.ts new file mode 100644 index 0000000000..fbf8f038f4 --- /dev/null +++ b/apps/desktop-host/tests/operation-diagnostics.spec.ts @@ -0,0 +1,41 @@ +import { Context } from '@deepseek-ai/cordis' +import type { PluginOperation } from '@deepseek-ai/dsh-plugin-manager/types' +import type { PluginOperationRunId } from '@deepseek-ai/dsh-plugin-manager/diagnostic' +import { expect, it, onTestFinished, vi } from 'vitest' +import { installOperationDiagnostics } from '../src/operation-diagnostics.ts' + +const operation: PluginOperation = { + id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9' as PluginOperation['id'], + time: '2026-10-19T00:00:00.000Z', reason: 'plugin', phase: 'started', target: 'managed', stage: 'enable', +} +const runId = 'c254ab28-97c9-4382-95e6-2db9c66f98c8' as PluginOperationRunId + +it('bounds outstanding deliveries and drains them on context disposal', async () => { + const ctx = new Context() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { release.resolve(undefined); await ctx.fiber.dispose() }) + const send = vi.fn(async () => { entered.resolve(undefined); await release.promise }) + installOperationDiagnostics(ctx, send, 1, runId) + ctx.emit('plugin-manager/operation', operation) + await entered.promise + for (let i = 0; i < 1000; i++) ctx.emit('plugin-manager/operation', operation) + expect(send).toHaveBeenCalledTimes(1) + expect(send).toHaveBeenCalledWith(expect.objectContaining({ runId })) + let stopped = false + const stopping = ctx.fiber.dispose().then(() => { stopped = true }) + await Promise.resolve(undefined) + expect(stopped).toBe(false) + release.resolve(undefined) + await stopping + ctx.emit('plugin-manager/operation', operation) + expect(send).toHaveBeenCalledTimes(1) +}) + +it('contains transport rejections during teardown', async () => { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + installOperationDiagnostics(ctx, async () => { throw new Error('disconnected') }, 1, runId) + ctx.emit('plugin-manager/operation', operation) + await expect(ctx.fiber.dispose()).resolves.toBeUndefined() +}) diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 58eaa722db..7b88b7a2e8 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -31,6 +31,9 @@ { "path": "../../packages/credentials/deepseek-account" }, + { + "path": "../../packages/boot/plugin-manager" + }, { "path": "../../packages/fs/fs" }, diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 01b1154394..e7fced6b8e 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -12,11 +12,11 @@ en: 7bc8bf30de693222 zh: e85993d8852b6cab /deepseek-harness-desktop/table-of-contents: - en: 05fd227f99ee535a - zh: 60d7d62bc0026973 + en: 127ea90553cb6af7 + zh: 7b1c5aec8558c684 /deepseek-harness-desktop/application-overview: - en: e9b22f232837e131 - zh: fc5f3834b969d2d2 + en: b57525e17b9638ab + zh: 274fe8344f7c2202 /deepseek-harness-desktop/terminal-command: en: 5f2d25a1d8472647 zh: 2acb5c5d7d1917e2 @@ -36,8 +36,8 @@ en: 160b500cdcd6a39a zh: 6c267f1762ec3689 /deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation: - en: a1a47b09f4a72ba4 - zh: 7b1cf27f0b72cf31 + en: 4951cd3ee659eb33 + zh: 96bdd6358dad587a /deepseek-harness-desktop/installation-ownership/host-environment: en: 98cf848762482d48 zh: 74f9100a39ec8c2f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1d5292c68b..f961a72a92 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -18,6 +18,7 @@ Desktop runs the Harness workspace in a native application. This reference cover - [Key technical decisions](#key-technical-decisions) - [Bundled command runtime](#bundled-command-runtime) - [Installation ownership](#installation-ownership) +- [Recovery and diagnostics](docs/diagnostics/README.md) - [Develop](#develop) - [Package](#package) - [Windows installer interface](#windows-installer-interface) @@ -30,6 +31,8 @@ Desktop runs the Harness workspace in a native application. This reference cover ## Application overview +Electron caches the last confirmed UI language in `userData/desktop/locale-cache` so startup recovery dialogs can use it before Host settings are available. Missing, invalid, or unreadable caches fall back to the system language; cache write failures do not block startup. A successful Host startup or a UI language change refreshes the cache. Until then, a changed system language may differ from the cached language. + Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited. The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. @@ -40,7 +43,7 @@ The first application-menu command, **About DeepSeek Harness**, opens Electron's Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. -Desktop ships one pnpm distribution at `resources/runtime/primary-runtime/dependencies/pnpm`. Build-time production installation, the installed `dsh` CLI, Creator and the Web Plugin Manager execute its `bin/pnpm.mjs` with Electron Node mode; package operations do not require pnpm on PATH. The private Node launcher environment applies only to package operations. Agent workspace dependencies copy the same self-contained primary-runtime payload into the Harness home and execute pnpm with its standalone Node. +Desktop ships one pnpm distribution at `resources/runtime/primary-runtime/dependencies/pnpm`, without the native packages of other targets. Build-time production installation, the installed `dsh` CLI, Creator and the Web Plugin Manager execute its `bin/pnpm.mjs` with Electron Node mode; package operations do not require pnpm on PATH. The private Node launcher environment applies only to package operations. Agent workspace dependencies copy the same self-contained primary-runtime payload into the Harness home and execute pnpm with its standalone Node. Embedded Platform documents use a persistent WebContentsView partition keyed by a hash of the Platform origin and stable account ID. localStorage preferences, including dismissed notices, survive view closure and application restart; different accounts and origins use separate storage. The Host supplies the account ID from its latest successful profile read; while no ID is known, the document opens in a disposable partition that keeps no preference across opens. Opening a persistent partition clears cookies, filesystem, IndexedDB, Cache Storage, the HTTP and shader caches, service workers, and HTTP authentication before the document loads; a disposable partition clears all of its storage. Closing a view destroys the document, removes its request interceptors, and schedules the same cleanup, so authentication left by an unclean exit is cleared before the next document loads. The next open and application shutdown await that cleanup, and an update install waits for it before the installer takes over the exit. A failed cleanup rejects that open and blocks the same account until a later cleanup succeeds; other accounts keep opening. Sign-out destroys the document but retains account-scoped preferences for the next sign-in. A document already open in a disposable partition stays mounted when the account ID arrives for the same credential; the next open uses the account-scoped partition. The [storage decision](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md) describes the retention policy. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar. @@ -172,13 +175,7 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version The [Web plugin UI](../../packages/client/ui-plugin-manager/README.md) owns the management interface. Desktop profile initialization and recovery preserve installed plugin files. -Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. The shared Web plugin manager reports package-operation errors; Host startup failures after plugin changes enter native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers only Exit and Restart. - -Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines, plus the path of the crash report described below when one was written. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely. - -Before the first fatal dialog opens, Electron writes a crash report to the platform logs directory (`app.getPath('logs')`: `~/Library/Logs/DeepSeek Harness` on macOS, `logs` under the application’s `userData` directory on Windows and Linux), waiting at most one second for the write; a slow or failed write shows the dialog without a path. The file `crash--.log` records the source (`host` for a Host exit, `web-boot` for a renderer boot failure, `renderer` for a renderer or document failure, `main` for a shell error), whether the backend had reached ready, application and runtime versions, the error with its enumerable properties and cause chain (cut at 256 KiB), the Host’s own inspected error (at most 64 KiB) when the Host reported a startup failure over IPC before exiting, and the primary window’s recent error-level console output (at most 64 KiB). A Host exit report therefore contains the retained stderr tail, which may include plugin output. A fatal failure during shutdown writes a report without opening a dialog. Files are owner-only where the platform supports it; startup keeps the ten newest reports and removes older ones without touching other files in the directory. - -Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document. +Desktop fatal failures use native recovery with Exit, Restart, Disable third-party plugins, back up profile patch, and restart, and Get diagnostic report. See [recovery and diagnostics](docs/diagnostics/README.md) for triggers, report locations and contents, recent operation records, and shutdown limits. ### Host environment diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index c2078e9d30..a7500d5d28 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -18,6 +18,7 @@ Desktop 在原生应用中运行 Harness 工作区。本参考文档说明应用 - [关键技术决策](#key-technical-decisions) - [内置命令运行时](#bundled-command-runtime) - [安装归属](#installation-ownership) +- [故障恢复与诊断](docs/diagnostics/README.zh.md) - [开发](#develop) - [打包](#package) - [Windows 安装界面](#windows-installer-interface) @@ -30,6 +31,8 @@ Desktop 在原生应用中运行 Harness 工作区。本参考文档说明应用 ## 应用概览 +Electron 在 `userData/desktop/locale-cache` 缓存最近确认的界面语言,使启动恢复弹窗在 Host 设置尚不可用时也能使用该语言。缓存缺失、无效或不可读时回退到系统语言;缓存写入失败不阻断启动。Host 成功启动或界面语言变更时会刷新缓存;在此之前,变更后的系统语言可能与缓存语言不同。 + 桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。 @@ -40,7 +43,7 @@ Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过 Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 -Desktop 在 `resources/runtime/primary-runtime/dependencies/pnpm` 中携带一份 pnpm 分发包。构建时的生产依赖安装、安装版 `dsh` CLI、Creator 和 Web 插件管理器通过 Electron Node 模式执行其中的 `bin/pnpm.mjs`;包操作不要求 PATH 上存在 pnpm。私有 Node 启动器环境仅用于包操作。agent(智能体)的工作区依赖将同一份完整 primary-runtime 复制到 Harness 主目录,并使用其中的独立 Node 执行 pnpm。 +Desktop 在 `resources/runtime/primary-runtime/dependencies/pnpm` 中携带一份 pnpm 分发包,不含其他目标平台的原生包。构建时的生产依赖安装、安装版 `dsh` CLI、Creator 和 Web 插件管理器通过 Electron Node 模式执行其中的 `bin/pnpm.mjs`;包操作不要求 PATH 上存在 pnpm。私有 Node 启动器环境仅用于包操作。agent(智能体)的工作区依赖将同一份完整 primary-runtime 复制到 Harness 主目录,并使用其中的独立 Node 执行 pnpm。 Platform 内嵌文档使用持久化 WebContentsView 分区,分区名由 Platform 来源和稳定账号 ID 的哈希决定。localStorage 中的页面偏好(包括已关闭的通知)在关闭视图和重启应用后保留;不同账号和来源使用独立存储。账号 ID 来自 Host 最近一次成功的资料读取;尚无该 ID 时,文档在一次性分区中打开,该分区不跨次保留偏好。打开持久分区会先清理 Cookie、文件系统、IndexedDB、Cache Storage、HTTP 与着色器缓存、Service Worker 及 HTTP 认证状态;一次性分区则清理其全部存储。关闭视图会销毁文档、移除请求拦截器并安排相同的清理,因此异常退出遗留的认证会在下一个文档加载前被清除。下次打开和应用退出都会等待该清理完成,更新安装也会在安装器接管退出前等待。清理失败会使该次打开失败,并在后续清理成功前阻止同一账号打开;其他账号不受影响。退出登录会销毁文档,但保留账号偏好供下次登录使用。同一凭证下账号 ID 迟到时,已以一次性分区打开的文档保持挂载;下次打开使用账号分区。[存储决策](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md)说明保留策略。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。 @@ -172,13 +175,7 @@ macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏 [Web 插件 UI](../../packages/client/ui-plugin-manager/README.zh.md)负责管理界面。Desktop profile 初始化和恢复保留已安装插件文件。 -主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。共享 Web 插件管理器报告包操作错误;插件变更后的 Host 启动失败会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,仅提供退出和重启。 - -原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断,若已写入下述崩溃报告则附上其路径。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。 - -首个致命弹窗打开前,Electron 会向平台日志目录(`app.getPath('logs')`:macOS 为 `~/Library/Logs/DeepSeek Harness`,Windows 与 Linux 为应用 `userData` 目录下的 `logs`)写入一份崩溃报告,最多等待写入一秒;写入缓慢或失败时弹窗不带路径。文件 `crash--.log` 记录来源(`host` 为 Host 退出、`web-boot` 为渲染进程启动失败、`renderer` 为渲染进程或文档失败、`main` 为壳自身错误)、后端是否已就绪、应用与运行时版本、包含可枚举属性与 cause 链的错误(截至 256 KiB)、Host 在退出前通过 IPC 报告启动失败时自己的 inspect 错误(最多 64 KiB),以及主窗口最近的 error 级 console 输出(最多 64 KiB)。因此 Host 退出报告包含保留的 stderr 尾部,其中可能含有插件输出。关闭过程中的致命失败只写报告、不弹窗。平台支持时文件仅所有者可读;启动时保留最新十份报告并删除更早的,不触碰目录中的其他文件。 - -恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。 +Desktop 的致命故障通过原生恢复对话框提供退出、重启、禁用第三方插件并备份 profile patch 后重启,以及获取诊断报告。触发范围、报告位置与内容、近期操作记录和退出限制见[故障恢复与诊断](docs/diagnostics/README.zh.md)。 ### Host 环境 diff --git a/apps/desktop/docs/diagnostics/README.i18n.yaml b/apps/desktop/docs/diagnostics/README.i18n.yaml new file mode 100644 index 0000000000..44994b4b1e --- /dev/null +++ b/apps/desktop/docs/diagnostics/README.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/docs/diagnostics/README.md +/: + en: 29d76ee1849cd8fd + zh: a2b8cbabe79557f9 +/desktop-recovery-and-diagnostics: + en: 4d4fd8a92047e5c6 + zh: 15ec51d703ccd70b +/desktop-recovery-and-diagnostics/summary: + en: e61d0f55e426aa6f + zh: dc13c7e054eac9d1 +/desktop-recovery-and-diagnostics/table-of-contents: + en: 8f3a2a84eaf575af + zh: 3087306f10e7c2cf +/desktop-recovery-and-diagnostics/recover-from-a-failure: + en: 9d0645dba352979d + zh: e1556207a04d9a71 +/desktop-recovery-and-diagnostics/report-contents: + en: 1a2eb71ac2dccf63 + zh: 5cf59639d3e49591 +/desktop-recovery-and-diagnostics/recent-operation-records: + en: f1c19c1516a51ab6 + zh: b350f7c18086d274 +/desktop-recovery-and-diagnostics/collection-and-shutdown: + en: bac4a49f63d4aaf8 + zh: df1ae908ee2148e1 diff --git a/apps/desktop/docs/diagnostics/README.md b/apps/desktop/docs/diagnostics/README.md new file mode 100644 index 0000000000..e0e72c3f1e --- /dev/null +++ b/apps/desktop/docs/diagnostics/README.md @@ -0,0 +1,76 @@ +--- +description: "Use this reference when recovering Desktop failures or collecting reports; it explains report contents, operation history, and shutdown limits." +--- + +# Desktop recovery and diagnostics + +English | [中文](README.zh.md) + +## Summary + +This reference describes Desktop native failure recovery, diagnostic reports, recent plugin operation records, and collection and shutdown limits. + +## Table of Contents + +- [Recover from a failure](#recovery) +- [Report contents](#report-contents) +- [Recent operation records](#operation-records) +- [Collection and shutdown](#collection-and-shutdown) + + + +## Recover from a failure + +Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers “Exit”, “Restart”, “Disable third-party plugins, back up profile patch, and restart”, and “Get diagnostic report” while report writing is pending or after it succeeds. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. The shared Web plugin manager reports package-operation errors; Host startup failures after plugin changes enter native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers “Exit”, “Restart”, and “Get diagnostic report” while report writing is pending or after it succeeds. + +Recovery uses the last confirmed Desktop language from the local cache before Host settings are available; an absent or invalid cache falls back to system languages. + +Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document. + + + +## Report contents + +Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines, plus the crash report path or its pending/unavailable status. Retrieving a pending or unavailable report preserves any recovery-operation failure headline and details. Configuration paths and startup entries precede the composition inventory so plugin rows cannot displace them when output is truncated. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely. + +Before the first fatal dialog opens, Electron writes a crash report to the platform logs directory (`app.getPath('logs')`: `~/Library/Logs/DeepSeek Harness` on macOS, `logs` under the application’s `userData` directory on Windows and Linux), waiting at most one second for the write; a slow or failed write shows the dialog with an explicit pending or unavailable status. Get diagnostic report checks the same write and reveals a saved file in the system file manager without restarting or stopping the Host; a write that completes after the initial deadline remains available. The user attaches that file to a support ticket. The attachment contains the original failure; later recovery-operation errors remain in the dialog and require a separate screenshot. Retrieving a saved report shows its sharing or reveal-failure heading while retaining the recovery error details. If writing fails, the dialog hides Get diagnostic report and suggests providing a screenshot. + +The file `crash--.log` records the source (`host` for a Host exit, `web-boot` for a renderer boot failure, `renderer` for a renderer or document failure, `main` for a shell error), whether the backend had reached ready, application and runtime versions, the error with its enumerable properties and cause chain (cut at 256 KiB), the Host’s own error and explicitly selected startup metadata (at most 64 Ki characters, including a truncation notice; startup errors reserve space for metadata by limiting the original error and startup-entry details to 8 Ki characters each, with explicit truncation notices) when the Host reported a startup failure over IPC before exiting, and the primary window’s recent error-level console output (at most 64 KiB). + +A Host exit report therefore contains the retained stderr tail, which may include plugin output. A fatal failure during shutdown writes a report without opening a dialog. Files are owner-only where the platform supports it; startup keeps the ten newest reports and removes older ones without touching other files in the directory. + + + +## Recent operation records + +Operation records and the application single-instance lock are isolated by `userData`; the shared platform logs directory does not store operation records. The reader only reads the two operation files under `userData/plugin-operations`. Repairing an unfinished append preserves complete lines in the current segment and the previous segment. + +Version-exemption changes reuse the bundle enable/disable record fields. When the exemption and installed versions match, the record cannot distinguish an exemption change from a bundle toggle. + +Host `transportDropped` is a lower bound as of the last successful delivery; Desktop rejects malformed or mismatched-run records without adding them to that counter. + +The journal retains two 256 KiB segments, accepts records up to 4 KiB, and bounds queued writes to 64 KiB. + +Exit, recovery restart and installer preparation wait at most 500 ms for queued writes; a timeout disables further writes and later drains return immediately without another wait, while an already-issued OS request may still settle. + +Records cover manager operations observed after Host startup, not direct CLI commands or manual file changes. Package-manager output remains in its existing detailed logs and is not copied into this summary. Missing records, a start without an end, and damaged history do not establish a successful operation. This collection does not diagnose kernel hangs, OOM or native crashes. + +Persistence failures also disable the writer for the rest of this shell lifetime, including after recovery or installer failure. This bounds repeated failing I/O and avoids appending behind an uncertain partial write. Restarting the application creates a new writer. Omission and repair counters cover only the current shell lifetime. Retained segments provide a larger local troubleshooting window than the deliberately smaller report attachment. + + + +## Collection and shutdown + +After saving the basic report, the shell attempts to append recent plugin-management records in an isolated Worker packaged as `lib/operation-report-worker.js` inside `app.asar`. The completion marker and omission counters distinguish a finished collection from missing or partial material. + +The Host announces its run ID before profile loading; reported Host errors carry that ID as `hostRunId`, matching operation records from the same lifetime. Failure before this announcement leaves the run identity unknown. Recent operations are context, not proof of cause. + +Collection reads only two owned files under `userData/plugin-operations`, at most 64 KiB and 100 records; the complete appended section is limited to 64 KiB. + +The 500 ms collection budget reserves its final 100 ms for cooperative cancellation before requesting forced Worker termination; completion waits for its exit. Application exit, recovery exit or restart, and confirmed update installation also cancel collection and wait at most 500 ms for Worker exit. A rejected shutdown cleanup is logged without skipping the remaining cleanup waits. Shutdown prevents late basic-report saves from starting another collector. An update installation failure permits future collection again. + +Worker messages interrupt collection between bounded records; an in-flight file open, stat, read or close must settle, and synchronous parsing cannot be preempted within one record. The final append uses an abort signal but can leave partial bytes. Forced termination remains the fallback when cooperative cleanup does not finish. + +The basic report remains available independently. Only one collector runs at a time; concurrent reports keep their basic content without an auxiliary section. A rejected termination request does not release that slot until exit is observed. Get diagnostic report can reveal the basic file before the append finishes. The completion marker confirms only that collection ended, not that all operations were captured; queued bytes and omission counters describe missing material. + +See [local verification](../../tests/README.md#verification-diagnostics) for fault setup and acceptance steps, and the [Desktop README](../../README.md) for the application overview and development entry points. diff --git a/apps/desktop/docs/diagnostics/README.zh.md b/apps/desktop/docs/diagnostics/README.zh.md new file mode 100644 index 0000000000..8e25e9721a --- /dev/null +++ b/apps/desktop/docs/diagnostics/README.zh.md @@ -0,0 +1,76 @@ +--- +description: "排查 Desktop 故障或收集报告时阅读,了解恢复操作、报告内容、操作历史和退出限制。" +--- + +# Desktop 故障恢复与诊断 + +[English](README.md) | 中文 + +## 概述 + +本参考文档说明 Desktop 的原生故障恢复、诊断报告、近期插件操作记录,以及采集和退出时的限制。 + +## 目录 + +- [遇到故障怎么办](#recovery) +- [报告包含什么](#report-contents) +- [近期操作记录](#operation-records) +- [采集与退出行为](#collection-and-shutdown) + + + +## 遇到故障怎么办 + +主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供“退出”“重启”“禁用第三方插件、备份 profile patch 并重启”,以及报告仍在保存或已保存时的“获取诊断报告”。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。共享 Web 插件管理器报告包操作错误;插件变更后的 Host 启动失败会进入原生恢复。不通过启动超时推断故障。包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,提供“退出”“重启”,以及报告仍在保存或已保存时的“获取诊断报告”。 + +Host 设置可用前,恢复对话框使用本地缓存中最后确认的 Desktop 语言;缓存不存在或无效时回退到系统语言。 + +恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。 + + + +## 报告包含什么 + +原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断,并附上崩溃报告路径或仍在保存、无法保存的状态。获取仍在保存或无法保存的报告时,保留恢复操作失败的标题和详情。配置路径和启动条目排在组合清单之前,避免输出截断时被插件记录挤掉。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。 + +首个致命弹窗打开前,Electron 会向平台日志目录(`app.getPath('logs')`:macOS 为 `~/Library/Logs/DeepSeek Harness`,Windows 与 Linux 为应用 `userData` 目录下的 `logs`)写入一份崩溃报告,最多等待写入一秒;写入缓慢或失败时明确显示仍在保存或无法保存。“获取诊断报告”检查同一次写入,并在系统文件管理器中定位已保存的文件,不停止 Host 或重启;超过首次等待期限后写入完成的文件仍可获取。用户可将文件作为工单附件。附件只包含最初的故障;后续恢复操作错误保留在对话框中,需要另行截图。获取已保存报告会显示分享或打开失败的标题,同时保留恢复错误详情。写入失败时隐藏获取报告按钮,并提示提供窗口截图。 + +文件 `crash--.log` 记录来源(`host` 为 Host 退出、`web-boot` 为渲染进程启动失败、`renderer` 为渲染进程或文档失败、`main` 为壳自身错误)、后端是否已就绪、应用与运行时版本、包含可枚举属性与 cause 链的错误(截至 256 KiB)、Host 在退出前通过 IPC 报告启动失败时的错误及显式选择的启动元数据(最多 64 Ki 个字符,包含截断提示;启动错误的原始错误文本和启动条目详情分别限制为 8 Ki 个字符,并明确标注截断,为结构化元数据保留空间),以及主窗口最近的 error 级 console 输出(最多 64 KiB)。 + +因此 Host 退出报告包含保留的 stderr 尾部,其中可能含有插件输出。关闭过程中的致命失败只写报告、不弹窗。平台支持时文件仅所有者可读;启动时保留最新十份报告并删除更早的,不触碰目录中的其他文件。 + + + +## 近期操作记录 + +操作记录目录与应用单实例锁均按 `userData` 隔离;共享平台日志目录不用于保存操作记录。读取器只读取 `userData/plugin-operations` 下的两个操作记录文件。修复未写完的尾行时保留当前段的完整行与上一段。 + +版本豁免变更复用 bundle 启停记录字段。豁免版本与安装版本相同时,记录无法区分豁免变更与 bundle 开关。 + +Host 的 `transportDropped` 只表示最近一次成功发送时的计数下界;Desktop 拒绝的非法或运行 ID 不匹配记录不计入该值。 + +操作历史保留两个 256 KiB 文件段,单条记录不超过 4 KiB,排队写入不超过 64 KiB。 + +退出、恢复重启和安装准备最多等待排队写入 500 ms;超时会停用后续写入,此后的排空调用立即返回,不会再次等待;但已经发出的操作系统请求仍可能随后结束。 + +记录覆盖 Host 启动后观察到的管理器操作,不包含直接执行的 CLI 命令或手工文件修改。包管理器输出保留在原有详细日志中,不复制到摘要。缺失记录、只有开始没有结束的操作以及损坏的历史,都不能说明操作已成功。此收集不诊断内核卡死、OOM 或原生崩溃。 + +持久化失败也会在本次壳生命周期内停用写入,包括恢复或安装器失败后继续运行的情况,以限制重复失败的 I/O,并避免在可能残缺的写入后追加记录。重启应用会创建新写入器。遗漏和修复计数仅覆盖当前壳生命周期。保留文件段提供比报告附件更大的本地排障历史窗口。 + + + +## 采集与退出行为 + +基本报告保存后,壳会尝试通过独立 Worker 追加近期插件管理记录;该 Worker 以 `lib/operation-report-worker.js` 打包在 `app.asar` 中。完成标记和遗漏计数用于区分已完成收集与缺失或不完整的材料。 + +Host 在加载 profile 前报告运行 ID;Host 错误中的 `hostRunId` 与该次运行的操作记录使用同一标识。在此通知之前发生的失败,其运行标识仍为未知。近期操作只是上下文,不是因果证据。 + +收集只读取 `userData/plugin-operations` 下归其管理的两个文件,最多读取 64 KiB 和 100 条记录;完整追加内容不超过 64 KiB。 + +500 ms 收集预算中的最后 100 ms 用于协作取消,随后才请求强制终止 Worker;只有 Worker 退出后才确认收集结束。应用退出、恢复退出或重启、确认安装更新也会取消收集,并最多等待 500 ms 确认 Worker 退出。退出清理中的拒绝会被记录,但不会跳过其他清理等待。退出过程中,延迟保存完成的基本报告不会再启动收集器。更新安装失败后可再次收集。 + +Worker 在有界记录之间处理取消消息;已经发出的文件打开、状态读取、读取或关闭操作必须等待完成,单条记录的同步解析不能被抢占。最后的追加写入传入取消信号,但仍可能留下部分字节。协作清理未及时完成时仍需强制终止。 + +基本报告独立可用。同时只运行一个收集器;并发报告保留基本内容,不追加辅助部分。终止请求被拒绝时,必须观察到退出才能释放该名额。“获取诊断报告”可能在追加完成前定位基本文件。完成标记仅表示收集结束,不表示捕获了全部操作;排队字节数和遗漏计数说明缺失材料。 + +故障构造与验收步骤见[本地验证](../../tests/README.zh.md#verification-diagnostics);应用概览与开发入口见 [Desktop README](../../README.zh.md)。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f8c411c367..b626dab3f9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -84,6 +84,8 @@ "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-atomic-write": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", + "@deepseek-ai/dsh-plugin-manager": "workspace:*", + "zod": "4.4.3", "@deepseek-ai/dsh-util-file-drag": "workspace:*" } } diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 1acb813253..cf0c19e895 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -139,6 +139,7 @@ export function createElectronBuilderConfig( }, files: [ 'lib/main.js', + 'lib/operation-report-worker.js', 'lib/welcome/**/*', 'lib/preload-app.cjs', 'lib/preload-mandatory.cjs', diff --git a/apps/desktop/src/crash-report.ts b/apps/desktop/src/crash-report.ts index 9c6d9ed793..b4c0140848 100644 --- a/apps/desktop/src/crash-report.ts +++ b/apps/desktop/src/crash-report.ts @@ -29,6 +29,8 @@ export interface CrashReportApp { /** Inputs of {@link renderCrashReport} and {@link writeCrashReport}. */ export interface CrashReportInput { + /** The shell will attempt an independently bounded operation-history append after saving this report. */ + readonly operationHistoryPlanned?: boolean readonly source: CrashReportSource readonly phase: CrashReportPhase readonly error: unknown @@ -125,6 +127,9 @@ export function renderCrashReport(input: CrashReportInput): string { ...(input.hostDiagnostic === undefined ? [] : ['--- host diagnostic (as reported by the Host process) ---', input.hostDiagnostic, '']), '--- renderer console (error level, oldest first) ---', consoleSection, + ...(input.operationHistoryPlanned === true ? ['', '--- auxiliary diagnostics ---', + 'Operation history is best effort. Without its completion marker below, it is unavailable or incomplete.', + 'Recent operations are context only. Missing records do not establish that no changes occurred.'] : []), '', ].join('\n') } diff --git a/apps/desktop/src/fatal-recovery.ts b/apps/desktop/src/fatal-recovery.ts index 5a739b1288..8bfe76ab37 100644 --- a/apps/desktop/src/fatal-recovery.ts +++ b/apps/desktop/src/fatal-recovery.ts @@ -12,10 +12,14 @@ interface RecoveryOperations { disablePlugins(): Promise exit(): void restart(): void + /** Reveal an existing report in the system file manager; reject if it is no longer available. */ + revealReport(path: string): Promise /** Persist the complete diagnostic; resolves with the file path, or `undefined` when nothing was written. */ writeReport(error: unknown, source: CrashReportSource): Promise } +type ReportState = { kind: 'pending' } | { kind: 'failed' } | { kind: 'ready'; path: string } + /** Upper bound on waiting for the crash report before the dialog opens. */ export const CRASH_REPORT_WAIT_MS = 1_000 @@ -25,28 +29,28 @@ const DETAIL_BUDGET = 1200 /** Tail lines of the error kept in the dialog; the report file holds the rest. */ const DETAIL_TAIL_LINES = 8 -/** - * Compose the dialog detail: the error's last lines (prefixed by the - * shortening notice when they are not the whole error), then the report path - * when one was written, then the reinstall advice. The report line does not - * depend on shortening: a short error that was persisted names its file too. +/** At most eight error-tail lines; status, advice and truncation notice share the detail character budget. + * The message heading has its own field. */ -function dialogDetail(error: string, messages: DesktopMessages, reportPath: string | undefined): string { +function dialogDetail(error: string, messages: DesktopMessages, reportState: ReportState): string { const advice = `\n\n${messages.startupReinstallAdvice}` - const report = reportLine(messages, reportPath) + const report = reportLine(messages, reportState) const tail = error.split(/\r\n|[\n\r\u2028\u2029]/u).slice(-DETAIL_TAIL_LINES).join('\n') const budget = DETAIL_BUDGET - advice.length - report.length - messages.diagnosticTruncated.length - 1 const shortened = tail.slice(-budget).replace(/^[\uDC00-\uDFFF]/u, '') return `${shortened === error ? error : `${messages.diagnosticTruncated}\n${shortened}`}${report}${advice}` } -function reportLine(messages: DesktopMessages, reportPath: string | undefined): string { - return reportPath === undefined ? '' : `\n${formatDesktopMessage(messages.reportWrittenTo, { path: reportPath })}` +function reportLine(messages: DesktopMessages, state: ReportState): string { + if (state.kind === 'pending') return `\n${messages.reportPending}` + if (state.kind === 'failed') return `\n${messages.reportUnavailable}` + return `\n${formatDesktopMessage(messages.reportWrittenTo, { path: state.path })}` } /** Deduplicates fatal reports while keeping explicit recovery-operation failures actionable. */ export class DesktopFatalRecovery { private reported = false + private reportState: ReportState = { kind: 'pending' } /** @param operations - Native presentation, report persistence, and application-owned shutdown operations. */ constructor(private readonly operations: RecoveryOperations) {} @@ -57,7 +61,7 @@ export class DesktopFatalRecovery { /** * Show the first fatal error; later reports cannot replace it or open another dialog. * The crash report is written first, bounded by {@link CRASH_REPORT_WAIT_MS}, so the dialog can name it; - * a slow or failed write shows the dialog without a path. + * a slow or failed write shows its status. Retrieving a report rechecks the same write. * @param error - Fatal failure, including nested diagnostic causes. * @param source - Where the failure surfaced, recorded in the report. * @returns Completion of the user's recovery action; duplicate reports resolve immediately. @@ -66,25 +70,42 @@ export class DesktopFatalRecovery { if (this.reported) return this.reported = true const messages = this.operations.messages() - const reportPath = await this.persist(error, source) + const report = this.persist(error, source) + await this.waitForReport(report) let detail = desktopErrorState(error).message let message = messages.fatalSummary for (;;) { const addressInUse = /\blisten EADDRINUSE\b/u.test(detail) + const buttons = addressInUse + ? [messages.exitApplication, messages.restartApplication] + : [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins] + const reportButton = this.reportState.kind === 'failed' ? undefined : buttons.length + if (reportButton !== undefined) buttons.push(messages.getDiagnosticReport) const { response } = await this.operations.show({ type: 'error', title: messages.startupFailed, message, detail: addressInUse - ? `${messages.startupAddressInUse}${reportLine(messages, reportPath)}` - : dialogDetail(detail, messages, reportPath), - buttons: addressInUse - ? [messages.exitApplication, messages.restartApplication] - : [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins], + ? `${messages.startupAddressInUse}${reportLine(messages, this.reportState)}` + : dialogDetail(detail, messages, this.reportState), + buttons, defaultId: 1, cancelId: 0, noLink: true, }) + if (response === reportButton) { + await this.waitForReport(report) + if (this.reportState.kind === 'ready') { + try { + await this.operations.revealReport(this.reportState.path) + message = messages.reportShareHint + } catch (failure) { + console.error('dsh desktop: could not reveal crash report', failure) + message = messages.reportRevealFailed + } + } + continue + } if (response === 0) { try { await this.operations.stop() } catch (failure) { console.error(failure) } this.operations.exit() @@ -103,16 +124,23 @@ export class DesktopFatalRecovery { } } - private async persist(error: unknown, source: CrashReportSource): Promise { - let timer: ReturnType | undefined + private async persist(error: unknown, source: CrashReportSource): Promise { try { - return await Promise.race([ - this.operations.writeReport(error, source), - new Promise((resolve) => { timer = setTimeout(() => { resolve(undefined) }, CRASH_REPORT_WAIT_MS) }), - ]) + const path = await this.operations.writeReport(error, source) + this.reportState = path === undefined ? { kind: 'failed' } : { kind: 'ready', path } } catch (failure) { console.error('dsh desktop: crash report failed', failure) - return undefined + this.reportState = { kind: 'failed' } + } + } + + private async waitForReport(report: Promise): Promise { + let timer: ReturnType | undefined + try { + await Promise.race([ + report, + new Promise((resolve) => { timer = setTimeout(resolve, CRASH_REPORT_WAIT_MS) }), + ]) } finally { clearTimeout(timer) } diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index 6dbb908c10..338f93bda4 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -2,8 +2,10 @@ import { spawn, type ChildProcess } from 'node:child_process' import { join } from 'node:path' +import { z } from 'zod' import type { PlatformSession } from '@deepseek-ai/dsh-deepseek-account' import { desktopNodeEnvironment } from './node-environment.ts' +import { parsePluginOperationRecord, type PluginOperationRecord, type PluginOperationRunId } from '@deepseek-ai/dsh-plugin-manager/diagnostic' interface ReadyEvent { readonly type: 'ready' @@ -23,7 +25,10 @@ interface PlatformSessionEvent { readonly session: PlatformSession | null } -type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { readonly type: 'shutdown-complete' } | { +type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { + readonly type: 'starting' + readonly runId: PluginOperationRunId +} | { readonly type: 'shutdown-complete' } | { readonly type: 'file-drag-resource' readonly requestId: number readonly resource: { readonly path: string; readonly sha256: string; readonly size: number } | null @@ -54,11 +59,14 @@ export interface DesktopQuitInspection { export const QUIT_INSPECTION_DEADLINE_MS = 2_000 const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024 +const runIdSchema = z.uuid() function isDesktopHostEvent(message: unknown): message is DesktopHostEvent { if (typeof message !== 'object' || message === null || !('type' in message)) return false const candidate = message as Record switch (candidate.type) { + case 'starting': + return runIdSchema.safeParse(candidate.runId).success case 'shutdown-complete': return true case 'ready': @@ -148,6 +156,7 @@ export class DesktopHostFatalError extends Error { /** One Web backend running under the Electron executable in Node mode. */ export class DesktopHostProcess { + private runId: PluginOperationRunId | undefined private child: ChildProcess | undefined private readyResolve!: (ready: DesktopHostReady) => void private readyReject!: (error: Error) => void @@ -177,6 +186,7 @@ export class DesktopHostProcess { * `office-skills` resources fail Host startup. * @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations. * @param onPlatformSession - Private credential updates for embedded Platform views. + * @param onPluginOperation - Optional diagnostic sink; malformed records are ignored without failing the Host. */ constructor( private readonly node: string, @@ -189,6 +199,7 @@ export class DesktopHostProcess { private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string }, private readonly onPlatformSession?: (session: PlatformSession | null) => void, + private readonly onPluginOperation?: (record: PluginOperationRecord) => void, ) {} /** @@ -216,12 +227,24 @@ export class DesktopHostProcess { child.stderr?.on('data', (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-MAX_HOST_DIAGNOSTIC_CHARS) }) child.stdout?.pipe(process.stdout) child.on('message', (message: unknown) => { + if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'plugin-operation') { + const record = 'record' in message ? parsePluginOperationRecord(message.record) : undefined + if (record !== undefined && record.runId === this.runId) { + try { this.onPluginOperation?.(record) } + catch (_error) { + // Diagnostic persistence cannot invalidate the Host's business IPC channel. + console.error('desktop plugin operation diagnostic sink failed') + } + } + return + } if (!isDesktopHostEvent(message)) { this.fail(new Error('dsh desktop host sent an invalid IPC event')) child.kill('SIGTERM') return } - if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections }) + if (message.type === 'starting') this.runId = message.runId + else if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections }) else if (message.type === 'platform-session') this.onPlatformSession?.(message.session) else if (message.type === 'shutdown-complete') { if (this.stopping) this.shutdownCompleted = true @@ -344,6 +367,7 @@ export class DesktopHostProcess { } private fail(error: Error): void { + if (this.runId !== undefined) Object.assign(error, { hostRunId: this.runId }) this.onPlatformSession?.(null) this.readyReject(error) for (const request of this.controlRequests.values()) request.reject(error) diff --git a/apps/desktop/src/locale-cache.ts b/apps/desktop/src/locale-cache.ts new file mode 100644 index 0000000000..d09ee4da24 --- /dev/null +++ b/apps/desktop/src/locale-cache.ts @@ -0,0 +1,38 @@ +/** Last confirmed shell language, available even when the Host cannot start. */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { resolveDesktopLocale, type DesktopLocale } from './locale.ts' + +/** + * Read the last confirmed language without requiring Host settings. + * @param userData - Electron userData directory. + * @returns Cached language, or undefined when missing, invalid or unreadable. + */ +export function readDesktopLocaleCache(userData: string): DesktopLocale['id'] | undefined { + try { + const value = readFileSync(join(userData, 'desktop', 'locale-cache'), 'utf8').trim() + const locale = resolveDesktopLocale(value) + if (locale.id === value) return locale.id + throw new Error('expected a canonical Desktop locale id') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('desktop locale cache: using system language because the cache could not be read', error) + } + return undefined + } +} + +/** + * Remember a confirmed language; write failure leaves the active language unchanged. + * @param userData - Electron userData directory. + * @param locale - Language confirmed by Host settings or the owned renderer. + */ +export function writeDesktopLocaleCache(userData: string, locale: DesktopLocale['id']): void { + try { + const directory = join(userData, 'desktop') + mkdirSync(directory, { recursive: true, mode: 0o700 }) + writeFileSync(join(directory, 'locale-cache'), `${locale}\n`, { mode: 0o600 }) + } catch (error) { + console.warn('desktop locale cache: language could not be saved for the next startup', error) + } +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index e5dcec40c8..74a1f19941 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -61,6 +61,11 @@ export const en = { startupAddressInUse: 'Another DSH instance (such as dsh web or the desktop app) is running. They cannot start at the same time. Quit the other running DSH instance, then restart.', diagnosticTruncated: '… Error details shortened.', reportWrittenTo: 'Diagnostic report: {path}', + getDiagnosticReport: 'Get diagnostic report', + reportPending: 'The diagnostic report is still being saved. Choose Get diagnostic report to check again.', + reportUnavailable: 'The diagnostic report could not be saved. You can provide a screenshot of this dialog when requesting support.', + reportShareHint: 'The report location has been opened. Attach the selected file to your support ticket.', + reportRevealFailed: 'The report location could not be opened. Use the path below to find the file.', startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.', exitApplication: 'Exit', restartApplication: 'Restart', @@ -235,6 +240,11 @@ export const zh = { startupAddressInUse: '有其他正在运行的 DSH(如其他 dsh web、桌面端),无法同时启动,请退出其他正在运行的 DSH 后重启。', diagnosticTruncated: '… 错误详情已截短。', reportWrittenTo: '诊断报告:{path}', + getDiagnosticReport: '获取诊断报告', + reportPending: '诊断报告仍在保存,可点击“获取诊断报告”再次检查。', + reportUnavailable: '诊断报告未能保存,反馈问题时可提供此窗口的截图。', + reportShareHint: '已打开报告位置,请将选中的文件附在支持工单中。', + reportRevealFailed: '无法打开报告位置,请通过下方路径查找文件。', startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。', exitApplication: '退出', restartApplication: '重启', @@ -407,6 +417,11 @@ export const ja = { startupAddressInUse: '別の DSH インスタンス (dsh web やデスクトップアプリなど) が実行中です。同時に起動することはできません。実行中の他の DSH インスタンスを終了してから再起動してください。', diagnosticTruncated: '… エラー詳細は省略されています。', reportWrittenTo: '診断レポート: {path}', + getDiagnosticReport: '診断レポートを取得', + reportPending: '診断レポートを保存中です。「診断レポートを取得」を選択して再度確認してください。', + reportUnavailable: '診断レポートを保存できませんでした。サポートへのお問い合わせ時に、このダイアログのスクリーンショットを提供できます。', + reportShareHint: 'レポートの保存場所を開きました。選択されたファイルをサポートチケットに添付してください。', + reportRevealFailed: 'レポートの保存場所を開けませんでした。以下のパスからファイルを探してください。', startupReinstallAdvice: 'アプリケーションファイルが見つからないか破損している場合は、アプリケーションを閉じて再インストールしてください。タスクは別途保存されています。', exitApplication: '終了', restartApplication: '再起動', @@ -579,6 +594,11 @@ export const es = { startupAddressInUse: 'Se está ejecutando otra instancia de DSH (como la web de dsh o la aplicación de escritorio). No pueden empezar al mismo tiempo. Salga de la otra instancia DSH en ejecución y luego reinicie.', diagnosticTruncated: '… Detalles del error acortados.', reportWrittenTo: 'Informe de diagnóstico: {path}', + getDiagnosticReport: 'Obtener informe de diagnóstico', + reportPending: 'El informe de diagnóstico se está guardando. Seleccione Obtener informe de diagnóstico para volver a comprobarlo.', + reportUnavailable: 'No se pudo guardar el informe de diagnóstico. Puede proporcionar una captura de pantalla de este diálogo al solicitar asistencia.', + reportShareHint: 'Se ha abierto la ubicación del informe. Adjunte el archivo seleccionado a su solicitud de asistencia.', + reportRevealFailed: 'No se pudo abrir la ubicación del informe. Use la ruta que aparece a continuación para encontrar el archivo.', startupReinstallAdvice: 'Si faltan archivos de la aplicación o están dañados, ciérrela y vuelva a instalarla. Sus tareas se almacenan por separado.', exitApplication: 'Salir', restartApplication: 'Reiniciar', @@ -751,6 +771,11 @@ export const fr = { startupAddressInUse: "Une autre instance DSH (telle que le Web dsh ou l'application de bureau) est en cours d'exécution. Ils ne peuvent pas démarrer en même temps. Quittez l'autre instance DSH en cours d'exécution, puis redémarrez.", diagnosticTruncated: '… Détails de l’erreur raccourcis.', reportWrittenTo: 'Rapport de diagnostic : {path}', + getDiagnosticReport: 'Obtenir le rapport de diagnostic', + reportPending: 'Le rapport de diagnostic est en cours d’enregistrement. Sélectionnez Obtenir le rapport de diagnostic pour vérifier à nouveau.', + reportUnavailable: 'Le rapport de diagnostic n’a pas pu être enregistré. Vous pouvez fournir une capture d’écran de cette boîte de dialogue lorsque vous contactez l’assistance.', + reportShareHint: 'L’emplacement du rapport a été ouvert. Joignez le fichier sélectionné à votre demande d’assistance.', + reportRevealFailed: 'L’emplacement du rapport n’a pas pu être ouvert. Utilisez le chemin ci-dessous pour trouver le fichier.', startupReinstallAdvice: "Si les fichiers de l'application sont manquants ou endommagés, fermez l'application et réinstallez-la. Vos tâches sont stockées séparément.", exitApplication: 'Quitter', restartApplication: 'Redémarrer', @@ -923,6 +948,11 @@ export const de = { startupAddressInUse: 'Eine andere DSH-Instanz (z. B. dsh Web oder die Desktop-App) wird ausgeführt. Sie können nicht gleichzeitig starten. Beenden Sie die andere laufende DSH-Instanz und starten Sie sie neu.', diagnosticTruncated: '… Fehlerdetails gekürzt.', reportWrittenTo: 'Diagnosebericht: {path}', + getDiagnosticReport: 'Diagnosebericht abrufen', + reportPending: 'Der Diagnosebericht wird noch gespeichert. Wählen Sie „Diagnosebericht abrufen“, um ihn erneut zu prüfen.', + reportUnavailable: 'Der Diagnosebericht konnte nicht gespeichert werden. Sie können bei einer Supportanfrage einen Screenshot dieses Dialogs bereitstellen.', + reportShareHint: 'Der Speicherort des Berichts wurde geöffnet. Fügen Sie die ausgewählte Datei Ihrer Supportanfrage bei.', + reportRevealFailed: 'Der Speicherort des Berichts konnte nicht geöffnet werden. Verwenden Sie den folgenden Pfad, um die Datei zu finden.', startupReinstallAdvice: 'Wenn Anwendungsdateien fehlen oder beschädigt sind, schließen Sie die Anwendung und installieren Sie sie erneut. Ihre Aufgaben werden separat gespeichert.', exitApplication: 'Beenden', restartApplication: 'Neustart', @@ -1095,6 +1125,11 @@ export const ru = { startupAddressInUse: 'Другой экземпляр DSH (например, веб-приложение dsh или настольное приложение) запущен. Они не могут начаться одновременно. Закройте другой работающий экземпляр DSH, а затем перезапустите.', diagnosticTruncated: '… Подробности об ошибках сокращены.', reportWrittenTo: 'Диагностический отчет: {path}', + getDiagnosticReport: 'Получить диагностический отчет', + reportPending: 'Диагностический отчет еще сохраняется. Выберите «Получить диагностический отчет», чтобы проверить еще раз.', + reportUnavailable: 'Не удалось сохранить диагностический отчет. При обращении в службу поддержки можно приложить снимок экрана этого диалогового окна.', + reportShareHint: 'Открыта папка с отчетом. Прикрепите выбранный файл к обращению в службу поддержки.', + reportRevealFailed: 'Не удалось открыть папку с отчетом. Найдите файл по указанному ниже пути.', startupReinstallAdvice: 'Если файлы приложения отсутствуют или повреждены, закройте приложение и переустановите его. Ваши задачи хранятся отдельно.', exitApplication: 'Выход', restartApplication: 'Перезапустить', @@ -1267,6 +1302,11 @@ export const pt = { startupAddressInUse: 'Outra instância DSH (como dsh web ou aplicativo de desktop) está em execução. Eles não podem começar ao mesmo tempo. Saia da outra instância DSH em execução e reinicie.', diagnosticTruncated: '… Detalhes do erro reduzidos.', reportWrittenTo: 'Relatório de diagnóstico: {path}', + getDiagnosticReport: 'Obter relatório de diagnóstico', + reportPending: 'O relatório de diagnóstico ainda está sendo salvo. Selecione Obter relatório de diagnóstico para verificar novamente.', + reportUnavailable: 'Não foi possível salvar o relatório de diagnóstico. Você pode fornecer uma captura de tela desta caixa de diálogo ao solicitar suporte.', + reportShareHint: 'O local do relatório foi aberto. Anexe o arquivo selecionado à sua solicitação de suporte.', + reportRevealFailed: 'Não foi possível abrir o local do relatório. Use o caminho abaixo para encontrar o arquivo.', startupReinstallAdvice: 'Se os arquivos do aplicativo estiverem ausentes ou danificados, feche o aplicativo e reinstale-o. Suas tarefas são armazenadas separadamente.', exitApplication: 'Sair', restartApplication: 'Reiniciar', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fb938dec84..4770fe83bf 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2,7 +2,7 @@ import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-prod import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ -import { readFile, writeFile } from 'node:fs/promises' +import { access, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { @@ -41,6 +41,8 @@ import { DesktopCommandManager } from './command-management.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' import { pruneCrashReports, RendererConsoleTail, writeCrashReport, type CrashReportSource } from './crash-report.ts' +import { OperationJournal } from './operation-journal.ts' +import { runOperationReportJob, type OperationReportJobResult } from './operation-report-job.ts' import { openWelcomeWindow } from './welcome-window.ts' import { WELCOME_IPC, needsWelcome, type WelcomeNotice } from './welcome-api.ts' import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts' @@ -55,6 +57,7 @@ import { desktopClientMetadata, desktopClientVersion } from './client-metadata.t import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts' import { DesktopPolicyTestAuth } from './policy-test-auth.ts' import { readDesktopSettings } from './settings.ts' +import { readDesktopLocaleCache, writeDesktopLocaleCache } from './locale-cache.ts' import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts' import { readDesktopRuntime } from './runtime-tree.ts' import { DesktopBrowserGuests } from './browser-guests.ts' @@ -65,14 +68,14 @@ import { DesktopTray } from './tray.ts' import { DesktopBackgroundNotice } from './background-notice.ts' let focusPrimaryWindow = (): void => {} -let stopForRecovery = async (): Promise => {} +let stopForRecovery = async (): Promise => { await stopOperationReports() } let shuttingDown = false /** * Set by quit entries that must not ask: crash recovery exit and restart, and the * development restart command. The installer handoff has its own before-quit branch. */ let skipQuitConfirmation = false -let windowsLanguage: string | undefined +let windowsLanguage: string | undefined = readDesktopLocaleCache(app.getPath('userData')) /** * Whether the backend has reached ready: false until the first ready, back to * false when a restart returns it to starting, frozen during shutdown so a @@ -106,10 +109,39 @@ const recovery = new DesktopFatalRecovery({ exit: () => { quitWithoutConfirmation() }, restart: () => { app.relaunch(); quitWithoutConfirmation() }, writeReport: (error, source) => persistCrashReport(error, source), + revealReport: async (path) => { await access(path); shell.showItemInFolder(path) }, }) +let operationJournal: OperationJournal | undefined +let operationReportJob: Promise | undefined +let operationReportAbort: AbortController | undefined +let operationReportsStopping = false +let operationReportStop: Promise | undefined + +/** Bound application shutdown waiting without claiming that an unresponsive Worker has exited. */ +function stopOperationReports(): Promise { + operationReportStop ??= waitForOperationReports() + return operationReportStop +} + +async function waitForOperationReports(): Promise { + operationReportsStopping = true + operationReportAbort?.abort() + const job = operationReportJob + if (job === undefined) return + let timer: ReturnType | undefined + try { + const exited = await Promise.race([job.then(() => true), new Promise((resolve) => { + timer = setTimeout(() => { resolve(false) }, 500) + })]) + if (!exited) console.error('dsh desktop: operation report Worker has not exited; continuing shutdown') + } finally { clearTimeout(timer) } +} + function persistCrashReport(error: unknown, source: CrashReportSource): Promise { - return writeCrashReport(app.getPath('logs'), { + const directory = app.getPath('logs') + const report = writeCrashReport(directory, { + operationHistoryPlanned: !shuttingDown && !operationReportsStopping, source, phase: backendReady ? 'running' : 'startup', error, @@ -121,6 +153,17 @@ function persistCrashReport(error: unknown, source: CrashReportSource): Promise< }, time: new Date(), }) + void report.then((path) => { + if (path !== undefined && operationReportJob === undefined && !shuttingDown && !operationReportsStopping) { + operationReportAbort = new AbortController() + operationReportJob = runOperationReportJob({ reportPath: path, directory: join(app.getPath('userData'), 'plugin-operations'), + limits: { bytes: 64 * 1024, recordBytes: 4096, records: 100, outputBytes: 64 * 1024 }, + journal: operationJournal?.status() ?? null, + }, { timeoutMs: 500, cancellationGraceMs: 100 }, operationReportAbort.signal) + void operationReportJob.finally(() => { operationReportJob = undefined; operationReportAbort = undefined }) + } + }).catch((_error: unknown) => { /* Basic persistence failure is owned by fatal recovery. */ }) + return report } function reportFatal(error: unknown, source: CrashReportSource): void { @@ -130,7 +173,12 @@ function reportFatal(error: unknown, source: CrashReportSource): void { void persistCrashReport(error, source) return } - void recovery.report(error, source).catch((failure: unknown) => { console.error(failure); app.exit(1) }) + void recovery.report(error, source).catch(async (failure: unknown) => { + console.error(failure) + shuttingDown = true + await stopOperationReports() + app.exit(1) + }) } protocol.registerSchemesAsPrivileged([{ @@ -374,7 +422,8 @@ async function main(): Promise { let updateStopFailure: DesktopHostUncleanExitError | undefined let updateState: DesktopUpdateState = { phase: 'idle' } const systemLanguages = app.getPreferredSystemLanguages() - let locale = resolveDesktopStartupLocale(null, systemLanguages) + let locale = windowsLanguage === undefined + ? resolveDesktopStartupLocale(null, systemLanguages) : resolveDesktopLocale(windowsLanguage) windowsLanguage = locale.id let mandatoryPolicy: DesktopMandatoryUpdatePolicy | undefined let mandatoryUI: DesktopMandatoryUpdateWindow | undefined @@ -470,7 +519,12 @@ async function main(): Promise { const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject, hostInspectPort, { ...hostEnvironment, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure, primaryRuntime, - resources, (next) => { platformView.setSession(next) }) + resources, (next) => { platformView.setSession(next) }, (record) => { + operationJournal ??= new OperationJournal(join(app.getPath('userData'), 'plugin-operations'), { + segmentBytes: 256 * 1024, recordBytes: 4096, queuedBytes: 64 * 1024, + }) + operationJournal.enqueue(JSON.stringify(record)) + }) return { start: async () => { const ready = await host.start() @@ -565,6 +619,10 @@ async function main(): Promise { fileDragStopped = false } const restoreHost = state.failedOperation === 'install' && updateStoppedHost && !quitting + if (state.failedOperation === 'install' && !quitting) { + operationReportsStopping = shuttingDown + operationReportStop = undefined + } shellInstallerOwnsQuit = false updateStoppedHost = false if (restoreHost) { @@ -593,7 +651,11 @@ async function main(): Promise { if (backend.host === undefined || welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable') return welcomeBackend.read() } - stopForRecovery = () => backend.close() + stopForRecovery = async () => { + const reports = stopOperationReports() + try { await backend.close() } + finally { await Promise.all([operationJournal?.drain(500), reports]) } + } const reconcileBackend = (): Promise => { startup ??= (async () => { @@ -651,14 +713,17 @@ async function main(): Promise { await disposeFileDrag().catch((error: unknown) => { console.error(error) }) requireCleanStop = true updateStopFailure = undefined - await backend.stop() + await Promise.all([backend.stop(), stopOperationReports()]) updateStoppedHost = true // The backend's async cleanup callback can assign this after the reset above. const stopFailure = updateStopFailure as DesktopHostUncleanExitError | undefined if (stopFailure !== undefined) throw new DesktopUpdatePreparationError('stop-failed', locale.messages.updateStopFailed, stopFailure.message) + await operationJournal?.drain(500) updateJournal?.action('install-confirmed') shellInstallerOwnsQuit = true } catch (error) { + operationReportsStopping = shuttingDown + operationReportStop = undefined if (!updateStoppedHost) await host.updateTasks('unlock').catch((unlockError: unknown) => { console.error(unlockError) }) throw error } finally { @@ -793,6 +858,7 @@ async function main(): Promise { if (window === undefined || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame || typeof next !== 'string') return const current = resolveDesktopStartupLocale(next, systemLanguages) + writeDesktopLocaleCache(app.getPath('userData'), current.id) if (current.id === locale.id) return locale = current platformView.notifyLocaleChanged() @@ -1076,13 +1142,11 @@ async function main(): Promise { Menu.buildFromTemplate(items).popup({ window, x: Math.round(x * zoom), y: Math.round(y * zoom), callback: resolve }) }) }) - ipcMain.on(DESKTOP_IPC.windowsAppearance, (event, language: unknown, color: unknown, symbolColor: unknown) => { + ipcMain.on(DESKTOP_IPC.windowsAppearance, (event, _language: unknown, color: unknown, symbolColor: unknown) => { if (mainWindow === undefined || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) return if (!event.senderFrame.url.startsWith(`${SCHEME}://app/`)) return - if (typeof language === 'string' && /^[a-zA-Z]+(?:-[a-zA-Z0-9]+)*$/u.test(language)) { - windowsLanguage = language - } + // Document lang starts as English before Host locale bootstrap; only localeChanged owns shell language. // Empty colors precede client stylesheet installation; only CSS color values cross IPC. const validColor = (value: unknown): value is string => typeof value === 'string' && /^(?:#[\da-f]{3,8}|rgba?\([\d.,%\s]+\))$/iu.test(value) @@ -1236,6 +1300,7 @@ async function main(): Promise { const state = await readWelcomeState() if (isQuitting() || backend.state.phase !== 'ready') return locale = resolveDesktopStartupLocale(state.localePreference, systemLanguages) + writeDesktopLocaleCache(app.getPath('userData'), locale.id) windowsLanguage = locale.id refreshApplicationMenu() if (!enteredWorkspace && needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey })) { @@ -1288,11 +1353,15 @@ async function main(): Promise { updateSchedule.dispose() updateDialog.dispose() mandatoryUI?.dispose() - void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close(), - // A Platform cleanup failure is logged without cutting the remaining Host shutdown short. - platformView.dispose().catch((error: unknown) => { console.error(error) }), - disposeFileDrag().catch((error: unknown) => { console.error(error) })]) - .catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() }) + void Promise.all([stopOperationReports(), Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), + backend.close().then(async () => { await operationJournal?.drain(500) }, async (error: unknown) => { + await operationJournal?.drain(500) + throw error + }), + platformView.dispose(), + disposeFileDrag(), + ].map(cleanup => cleanup.catch((error: unknown) => { console.error(error) }))) + .then(() => { app.quit() }) } app.on('before-quit', (event) => { if (shellInstallerOwnsQuit) { @@ -1381,7 +1450,9 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined) } reportFatal(error, 'main') -}).catch((error: unknown) => { +}).catch(async (error: unknown) => { console.error(error) + shuttingDown = true + await stopOperationReports() app.exit(1) }) diff --git a/apps/desktop/src/operation-history.ts b/apps/desktop/src/operation-history.ts new file mode 100644 index 0000000000..ca9584f79f --- /dev/null +++ b/apps/desktop/src/operation-history.ts @@ -0,0 +1,120 @@ +/** Bounded, cancellable reads of Desktop-owned plugin operation history. */ +import { open } from 'node:fs/promises' +import { join } from 'node:path' +import { setImmediate } from 'node:timers/promises' +import { parsePluginOperationRecord, type PluginOperationRecord } from '@deepseek-ai/dsh-plugin-manager/diagnostic' + +/** Resource budgets for auxiliary history collection, independent of basic crash report writing. */ +export interface OperationHistoryReadLimits { + readonly bytes: number + readonly recordBytes: number + readonly records: number +} + +/** Collection facts; incomplete history must not be interpreted as absence of plugin changes. */ +export interface OperationHistoryReadResult { + readonly records: readonly PluginOperationRecord[] + readonly bytesRead: number + /** Distinct files from which at least one byte was read. */ + readonly filesRead: number + readonly truncated: boolean + readonly invalidRecords: number + readonly unavailableFiles: number + readonly cancelled: boolean +} + +/** + * Read only the bounded tails of the two owned history files, newest segment first. + * Cancellation is observed between bounded records after yielding to Worker messages and stops subsequent I/O and parsing. + * An already-issued OS read must settle before its handle closes. + * @param directory - Desktop's operation history directory; unrelated files are never enumerated. + * @param limits - Positive byte, record-size and record-count budgets. + * @param signal - Caller-owned collection deadline or shutdown cancellation. + * @returns Partial records and explicit omissions; filesystem failures never reject the collection. + */ +export async function readOperationHistory( + directory: string, + limits: OperationHistoryReadLimits, + signal: AbortSignal, +): Promise { + if (![limits.bytes, limits.recordBytes, limits.records].every(value => Number.isSafeInteger(value) && value > 0)) { + throw new RangeError('Operation history budgets must be positive safe integers') + } + await setImmediate() + const cancelled = (): boolean => signal.aborted + const records: PluginOperationRecord[] = [] + let bytesRead = 0 + const seenFiles = new Set() + let truncated = false + let invalidRecords = 0 + let unavailableFiles = 0 + for (const name of ['operations.jsonl', 'operations.previous.jsonl']) { + if (cancelled()) break + try { + await using handle = await open(join(directory, name), 'r') + if (cancelled()) break + const fileStat = await handle.stat() + if (cancelled()) break + if (!fileStat.isFile()) { + unavailableFiles++ + continue + } + const identity = `${String(fileStat.dev)}:${String(fileStat.ino)}` + if (seenFiles.has(identity)) { + truncated = true + continue + } + if (fileStat.size > 0 && (bytesRead >= limits.bytes || records.length >= limits.records)) { + truncated = true + break + } + + const readLength = Math.min(fileStat.size, limits.bytes - bytesRead) + const readStart = fileStat.size - readLength + const buffer = Buffer.alloc(readLength) + let readOffset = 0 + while (readOffset < readLength && !cancelled()) { + const read = await handle.read(buffer, readOffset, Math.min(16 * 1024, readLength - readOffset), readStart + readOffset) + bytesRead += read.bytesRead + if (read.bytesRead === 0) break + readOffset += read.bytesRead + } + if (readOffset > 0) seenFiles.add(identity) + if (cancelled()) break + truncated ||= readStart > 0 || readOffset < readLength + + // An unterminated last record can still be in flight; never parse it as a committed record. + if (readOffset === 0) continue + let lineEnd = buffer.lastIndexOf(10, readOffset - 1) + if (lineEnd !== readOffset - 1) truncated = true + while (lineEnd >= 0 && records.length < limits.records && !cancelled()) { + await setImmediate() + if (cancelled()) break + const previousLineEnd = lineEnd === 0 ? -1 : buffer.lastIndexOf(10, lineEnd - 1) + if (previousLineEnd < 0 && readStart > 0) { + truncated = true + break + } + const recordBytes = lineEnd - previousLineEnd - 1 + if (recordBytes > limits.recordBytes) { + invalidRecords++ + } else if (recordBytes > 0) { + try { + const value: unknown = JSON.parse(buffer.subarray(previousLineEnd + 1, lineEnd).toString('utf8')) + const record = parsePluginOperationRecord(value) + if (record === undefined) invalidRecords++ + else records.push(record) + } catch (_error) { + // A corrupt record does not invalidate independently delimited older records. + invalidRecords++ + } + } + lineEnd = previousLineEnd + } + if (lineEnd >= 0) truncated = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') unavailableFiles++ + } + } + return { records, bytesRead, filesRead: seenFiles.size, truncated, invalidRecords, unavailableFiles, cancelled: cancelled() } +} diff --git a/apps/desktop/src/operation-journal.ts b/apps/desktop/src/operation-journal.ts new file mode 100644 index 0000000000..b72cc6baac --- /dev/null +++ b/apps/desktop/src/operation-journal.ts @@ -0,0 +1,208 @@ +/** Single-shell, bounded persistence for already-projected operation diagnostics. */ +import { mkdir, open, rename, stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +/** Budgets include serialized JSON and its line delimiter. */ +export interface OperationJournalLimits { + readonly segmentBytes: number + readonly recordBytes: number + readonly queuedBytes: number +} + +/** In-memory omissions for the current shell lifetime; missing records never imply no operations occurred. */ +export interface OperationJournalStatus { + readonly dropped: number + readonly failed: number + readonly queuedBytes: number + readonly disabled: boolean + /** Damaged or oversized segments repaired; their lost record count is unknown. */ + readonly repairedSegments: number +} + +/** Owns two known files and one serial writer; the owning shell must exclude other writers. */ +export class OperationJournal { + private readonly queue: string[] = [] + private queuedBytes = 0 + private dropped = 0 + private failed = 0 + private repairedSegments = 0 + private disabled = false + private closed = false + private writing: Promise | undefined + private initialized = false + private readonly abort = new AbortController() + + /** + * @param directory - Directory reserved for the shell's operation journal. + * @param limits - Positive byte budgets, with recordBytes no greater than either other budget. + */ + constructor( + private readonly directory: string, + private readonly limits: OperationJournalLimits, + ) { + if (![limits.segmentBytes, limits.recordBytes, limits.queuedBytes].every(value => Number.isSafeInteger(value) && value > 0) + || limits.recordBytes > limits.segmentBytes || limits.recordBytes > limits.queuedBytes) { + throw new RangeError('Invalid operation journal budgets') + } + } + + /** + * Queue one prevalidated, whitelisted JSON record without awaiting disk I/O. + * @param record - An already serialized JSON object without literal line delimiters. + * @returns Whether the record was accepted; disk failures are reported separately by status(). + */ + enqueue(record: string): boolean { + const line = record + '\n' + const bytes = Buffer.byteLength(line) + if (this.closed || this.disabled || bytes > this.limits.recordBytes + || bytes + this.queuedBytes > this.limits.queuedBytes || /[\r\n]/u.test(record)) { + this.dropped++ + return false + } + this.queue.push(line) + this.queuedBytes += bytes + this.startWriter() + return true + } + + /** @returns Current queue occupancy and omission counters. */ + status(): OperationJournalStatus { + return { + dropped: this.dropped, + failed: this.failed, + queuedBytes: this.queuedBytes, + disabled: this.disabled, + repairedSegments: this.repairedSegments, + } + } + + /** Stop accepting records and await completion of the owned writer. */ + async close(): Promise { + this.closed = true + while (this.writing !== undefined) await this.writing + } + + /** + * Wait for queued records during shutdown or installer handoff without holding the application indefinitely. + * @param timeoutMs - Waiting budget before disabling the writer and cancelling subsequent I/O. + * @returns True after the queue settles successfully; false immediately when disabled, or on failure or deadline. + * False does not confirm that an issued OS request has settled. + */ + async drain(timeoutMs: number): Promise { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new RangeError('Invalid journal drain deadline') + if (this.disabled) return false + let timer: ReturnType | undefined + try { + return await Promise.race([ + (async () => { + while (this.writing !== undefined) await this.writing + return !this.disabled + })(), + new Promise((resolve) => { + timer = setTimeout(() => { + this.disabled = true + this.abort.abort() + resolve(false) + }, timeoutMs) + }), + ]) + } finally { + clearTimeout(timer) + } + } + + private startWriter(): void { + this.writing ??= this.writeQueue().finally(() => { + this.writing = undefined + if (this.queue.length > 0) this.startWriter() + }) + } + + private async writeQueue(): Promise { + try { + await mkdir(this.directory, { recursive: true, mode: 0o700 }) + this.abort.signal.throwIfAborted() + const current = join(this.directory, 'operations.jsonl') + const previous = join(this.directory, 'operations.previous.jsonl') + if (!this.initialized) { + await this.prepareSegments(current, previous) + this.initialized = true + } + for (let line = this.queue[0]; line !== undefined; line = this.queue[0]) { + this.abort.signal.throwIfAborted() + const bytes = Buffer.byteLength(line) + let size = 0 + try { + size = (await stat(current)).size + } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + this.abort.signal.throwIfAborted() + if (size > this.limits.segmentBytes) { + // An oversized pre-existing segment is not copied into the bounded retained history. + await writeFile(current, '', { mode: 0o600 }) + this.repairedSegments++ + } else if (size + bytes > this.limits.segmentBytes) { + await rename(current, previous) + } + this.abort.signal.throwIfAborted() + await writeFile(current, line, { flag: 'a', mode: 0o600, signal: this.abort.signal }) + this.queue.shift() + this.queuedBytes -= bytes + } + } catch (_error) { + // A failed append may have written a partial line; disable this writer rather than concatenate another record. + this.disabled = true + this.failed += this.queue.length + this.queue.length = 0 + this.queuedBytes = 0 + } + } + + private async prepareSegments(current: string, previous: string): Promise { + try { + const size = (await stat(previous)).size + this.abort.signal.throwIfAborted() + if (size > this.limits.segmentBytes) { + await writeFile(previous, '', { mode: 0o600 }) + this.repairedSegments++ + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + this.abort.signal.throwIfAborted() + { + await using handle = await open(current, 'r+').catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + this.abort.signal.throwIfAborted() + return open(current, 'wx+', 0o600) + }) + this.abort.signal.throwIfAborted() + const size = (await handle.stat()).size + this.abort.signal.throwIfAborted() + if (size > this.limits.segmentBytes) { + await handle.truncate(0) + this.repairedSegments++ + } else if (size > 0) { + const byte = Buffer.alloc(1) + const read = await handle.read(byte, 0, 1, size - 1) + if (read.bytesRead !== 1 || byte[0] !== 10) { + const buffer = Buffer.alloc(size) + let offset = 0 + while (offset < size) { + this.abort.signal.throwIfAborted() + const chunk = await handle.read(buffer, offset, size - offset, offset) + if (chunk.bytesRead === 0) break + offset += chunk.bytesRead + } + this.abort.signal.throwIfAborted() + // Keep committed lines in both segments; only the unfinished append is discarded. + await handle.truncate(buffer.subarray(0, offset).lastIndexOf(10) + 1) + this.repairedSegments++ + } + } + } + this.abort.signal.throwIfAborted() + } +} diff --git a/apps/desktop/src/operation-report-job.ts b/apps/desktop/src/operation-report-job.ts new file mode 100644 index 0000000000..b78259df78 --- /dev/null +++ b/apps/desktop/src/operation-report-job.ts @@ -0,0 +1,106 @@ +/** Worker lifetime for an auxiliary report; completion includes worker exit. */ +import { Worker } from 'node:worker_threads' +import type { OperationReportLimits } from './operation-report.ts' +import type { OperationJournalStatus } from './operation-journal.ts' + +/** Facts passed to the isolated collector after the basic report is saved. */ +export interface OperationReportJobInput { + readonly reportPath: string + readonly directory: string + readonly limits: OperationReportLimits + readonly journal: OperationJournalStatus | null +} + +/** Collection and cooperative shutdown share one total budget. */ +export interface OperationReportJobOptions { + /** Deadline for requesting forced termination, including Worker startup. */ + readonly timeoutMs: number + /** Reserved part of timeoutMs for cancellation and resource cleanup. */ + readonly cancellationGraceMs: number +} + +/** Cancellation can leave a partial append; the independently saved basic report remains valid. */ +export interface OperationReportJobResult { + readonly appended: boolean + readonly timedOut: boolean + readonly cancelled: boolean +} + +/** + * Collect history, request cooperative cancellation, then force termination if cleanup exceeds its budget. + * @param input - Bounded collection request for an existing basic report. + * @param options - Total deadline and the cleanup window reserved within it. + * @param signal - Application shutdown or recovery cancellation; an already-aborted signal prevents startup. + * @returns Append outcome only after Worker exit; cancellation or termination requests alone do not confirm exit. + */ +export async function runOperationReportJob( + input: OperationReportJobInput, + options: OperationReportJobOptions, + signal: AbortSignal, +): Promise { + const { timeoutMs, cancellationGraceMs } = options + if (!Number.isSafeInteger(timeoutMs) || !Number.isSafeInteger(cancellationGraceMs) + || cancellationGraceMs < 1 || timeoutMs <= cancellationGraceMs) throw new RangeError('Invalid operation report deadlines') + if (signal.aborted) return { appended: false, timedOut: false, cancelled: true } + const terminationDeadline = performance.now() + timeoutMs + let worker: Worker + try { + worker = new Worker(new URL('./operation-report-worker.js', import.meta.url), { + workerData: input, + resourceLimits: { + maxOldGenerationSizeMb: 32, + maxYoungGenerationSizeMb: 8, + stackSizeMb: 2, + }, + }) + } + catch (_error) { + return { appended: false, timedOut: false, cancelled: false } + } + return new Promise((resolve) => { + let appended = false + let failed = false + let timedOut = false + let cancelled = false + let terminationTimer: ReturnType | undefined + + const terminateWorker = (): void => { + void worker.terminate().catch(() => { + failed = true + }) + } + + const requestCancellation = (): void => { + if (cancelled) return + cancelled = true + clearTimeout(collectionTimer) + try { + worker.postMessage('cancel') + } + catch (_error) { + failed = true + } + terminationTimer = setTimeout(terminateWorker, Math.max(0, Math.min(cancellationGraceMs, terminationDeadline - performance.now()))) + } + + const collectionTimer = setTimeout(() => { + timedOut = true + requestCancellation() + }, Math.max(0, terminationDeadline - performance.now() - cancellationGraceMs)) + + signal.addEventListener('abort', requestCancellation, { once: true }) + worker.on('message', (message: unknown) => { + appended = message === true + }) + worker.once('error', () => { + failed = true + }) + worker.once('exit', (code) => { + clearTimeout(collectionTimer) + clearTimeout(terminationTimer) + signal.removeEventListener('abort', requestCancellation) + resolve({ appended: appended && code === 0 && !failed && !cancelled, timedOut, cancelled }) + }) + if (signal.aborted) requestCancellation() + }) +} diff --git a/apps/desktop/src/operation-report-worker.ts b/apps/desktop/src/operation-report-worker.ts new file mode 100644 index 0000000000..c7ee527b77 --- /dev/null +++ b/apps/desktop/src/operation-report-worker.ts @@ -0,0 +1,36 @@ +/** Isolated auxiliary-report I/O; the shell owns termination and observes worker exit. */ +import { parentPort, workerData } from 'node:worker_threads' +import { z } from 'zod' +import { appendOperationReport } from './operation-report.ts' + +const positive = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) +const input = z.object({ + reportPath: z.string(), + directory: z.string(), + limits: z.object({ + bytes: positive, + recordBytes: positive, + records: positive, + outputBytes: positive, + }), + journal: z.object({ + dropped: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + repairedSegments: z.number().int().nonnegative(), + queuedBytes: z.number().int().nonnegative(), + disabled: z.boolean(), + }).nullable(), +}).parse(workerData) + +const cancellation = new AbortController() +const cancel = (message: unknown): void => { + if (message === 'cancel') cancellation.abort() +} +parentPort?.on('message', cancel) +try { + const appended = await appendOperationReport(input.reportPath, input.directory, input.limits, cancellation.signal, input.journal) + parentPort?.postMessage(appended) +} finally { + parentPort?.off('message', cancel) + parentPort?.close() +} diff --git a/apps/desktop/src/operation-report.ts b/apps/desktop/src/operation-report.ts new file mode 100644 index 0000000000..2c023abf82 --- /dev/null +++ b/apps/desktop/src/operation-report.ts @@ -0,0 +1,62 @@ +/** Best-effort operation history appended only after the independent basic report exists. */ +import { writeFile } from 'node:fs/promises' +import { setImmediate } from 'node:timers/promises' +import { readOperationHistory, type OperationHistoryReadLimits } from './operation-history.ts' +import type { OperationJournalStatus } from './operation-journal.ts' + +/** Read budgets and the complete appended section's UTF-8 byte budget. */ +export interface OperationReportLimits extends OperationHistoryReadLimits { + readonly outputBytes: number +} + +/** + * Append a bounded summary without rewriting the basic report's bytes. + * A missing completion marker means the auxiliary section is unavailable or incomplete. + * @param reportPath - Successfully saved basic report. + * @param directory - Owned operation history directory. + * @param limits - Read and parse budgets. + * @param signal - Collection deadline or shutdown cancellation; already-issued OS I/O must settle. + * @param journal - Current writer status, or null when this shell has not received operation records. + * @returns Whether the complete auxiliary section was appended. + */ +export async function appendOperationReport( + reportPath: string, + directory: string, + limits: OperationReportLimits, + signal: AbortSignal, + journal: OperationJournalStatus | null, +): Promise { + try { + if (!Number.isSafeInteger(limits.outputBytes) || limits.outputBytes < 1) return false + const cancelled = (): boolean => signal.aborted + const history = await readOperationHistory(directory, limits, signal) + if (cancelled()) return false + + const { records, ...facts } = history + const renderHeader = (omitted: number): string => '\n--- recent plugin operations (context, not proof of cause) ---\n' + + JSON.stringify({ journal, ...facts, outputRecordsOmitted: omitted }).slice(0, -1) + ',"records":[' + const completionFooter = ']}\n--- operation history collection complete; consult omission counters above ---\n' + let outputBytes = Buffer.byteLength(renderHeader(records.length) + completionFooter) + if (outputBytes > limits.outputBytes) return false + + const encodedRecords: string[] = [] + for (const record of records) { + await setImmediate() + if (cancelled()) return false + const encodedRecord = JSON.stringify(record) + const additionalBytes = Buffer.byteLength(encodedRecord) + (encodedRecords.length === 0 ? 0 : 1) + if (outputBytes + additionalBytes > limits.outputBytes) break + encodedRecords.push(encodedRecord) + outputBytes += additionalBytes + } + + const reportSection = renderHeader(records.length - encodedRecords.length) + encodedRecords.join(',') + completionFooter + await setImmediate() + if (cancelled()) return false + await writeFile(reportPath, reportSection, { flag: 'a', signal }) + return true + } catch (_error) { + // The independently written basic report remains usable, including after a partial append. + return false + } +} diff --git a/apps/desktop/tests/README.i18n.yaml b/apps/desktop/tests/README.i18n.yaml index fbb1c7d3e9..97e9812489 100644 --- a/apps/desktop/tests/README.i18n.yaml +++ b/apps/desktop/tests/README.i18n.yaml @@ -2,27 +2,30 @@ # section, a hash of its English and Chinese blocks outside code blocks and generated regions. # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/tests/README.md -/desktop-local-update-verification: - en: 194f1f0f1ca90743 - zh: 773ca28e04c8911f -/desktop-local-update-verification/summary: +/desktop-local-verification: + en: 5056a9b4d24c1880 + zh: 4d0c9d8e5f69d153 +/desktop-local-verification/summary: en: 94559f431b0b2151 zh: 1a22166a2bc59686 -/desktop-local-update-verification/table-of-contents: - en: 86afdf78fa613876 - zh: 66fad143d99e999f -/desktop-local-update-verification/update-dialog-themes: +/desktop-local-verification/table-of-contents: + en: fa9e3048995c0e3c + zh: ef30232afa4ba125 +/desktop-local-verification/fault-report-verification: + en: a418eb04ce93b1ee + zh: 1ec04872a66ce1b7 +/desktop-local-verification/update-dialog-themes: en: d086c3b38ce623f1 zh: 8327c31092f5f174 -/desktop-local-update-verification/native-overlay-visibility: +/desktop-local-verification/native-overlay-visibility: en: 6f4de4503748f3e2 zh: 648370375f16e463 -/desktop-local-update-verification/manual-walkthrough: +/desktop-local-verification/manual-walkthrough: en: c4b4ee8763d8464a zh: ee2159bc08966592 -/desktop-local-update-verification/evidence: +/desktop-local-verification/evidence: en: dcb2473264c825d4 zh: cebb2832efa15974 -/desktop-local-update-verification/open-verification: +/desktop-local-verification/open-verification: en: 20e9ea47acb1ff50 zh: acce24b56d57e12c diff --git a/apps/desktop/tests/README.md b/apps/desktop/tests/README.md index 400bfb0c00..cda4533699 100644 --- a/apps/desktop/tests/README.md +++ b/apps/desktop/tests/README.md @@ -1,4 +1,4 @@ -# Desktop local update verification +# Desktop local verification English | [中文](README.zh.md) @@ -10,12 +10,26 @@ Installed qualification can opt into `DSH_DESKTOP_UPDATE_JOURNAL_DIR`, an absolu ## Table of Contents +- [Fault-report verification](#verification-diagnostics) - [Update dialog themes](#verification-theme) - [Native overlay visibility](#verification-overlay) - [Evidence](#verification-evidence) - [Manual walkthrough](#verification-interactive) - [Open verification](#verification-open) + + +## Fault-report verification + +See [recovery and diagnostics](../docs/diagnostics/README.md) for product behavior and limitations. + +Use a disposable profile and separate Electron `userData` with the actual packaged client. Construct a minimal local bundle with `package.json`, `dsh.bundle.patch` pointing to `cordis.patch.yml`, and an optional plugin module; archive it as a local `.tgz`. These packages deliberately break the application and must not be published or installed in a daily profile. + +Exercise import and plugin-initialization exceptions, an asynchronous exception after `appReady`, and a nonzero Host exit. A patch disabling the `agent`, `session`, `llm`, and `tools` providers exercises missing-service diagnostics. To check cross-restart history, select that bundle through `pluginManager`, then restart the same profile. Runtime scenarios require observing actual client readiness; `appReady` alone does not establish that the window is visible. + +Check the original error and cause or exit code, operation and Host run IDs across restart, report-collection completion and omissions, file retrieval from the native dialog, and startup after plugin recovery. Record the package version and source commit; successful package generation or a management result alone does not establish client functionality. Restore protocol associations and stop test processes afterward. + +For resource checks, use large sparse history files, unrelated files, dense records, and malformed or unfinished lines. Measure setup separately from collection in fresh processes, recording actual bytes read, output size, time, and peak memory. To check Worker cancellation, a test-only module hook can hold an asynchronous file open after writing an entry marker. Confirm that marker, observe Worker exit after the deadline, and compare the basic report bytes. This does not reproduce a kernel-hung system call or establish packaged-client behavior. ## Update dialog themes diff --git a/apps/desktop/tests/README.zh.md b/apps/desktop/tests/README.zh.md index b52cf6309e..6cca69e8ac 100644 --- a/apps/desktop/tests/README.zh.md +++ b/apps/desktop/tests/README.zh.md @@ -1,4 +1,4 @@ -# Desktop 本地更新验证 +# Desktop 本地验证 [English](README.md) | 中文 @@ -10,12 +10,26 @@ ## 目录 +- [故障报告验证](#verification-diagnostics) - [更新弹窗主题](#verification-theme) - [原生浮层可见性](#verification-overlay) - [验证证据](#verification-evidence) - [手动演练](#verification-interactive) - [待验证事项](#verification-open) + + +## 故障报告验证 + +产品行为与限制见[故障恢复与诊断](../docs/diagnostics/README.zh.md)。 + +使用可丢弃的 profile、独立的 Electron `userData` 和实际打包客户端。构造最小本地组合包:`package.json` 的 `dsh.bundle.patch` 指向 `cordis.patch.yml`,需要执行代码时再加入插件模块,然后打成可本地安装的 `.tgz`。这些包会故意使应用出错,不得发布或安装到日常 profile。 + +分别构造模块导入异常、插件初始化异常、`appReady` 后的异步异常,以及 Host 非零退出。用 patch 禁用 `agent`、`session`、`llm`、`tools` 提供者,检查服务缺失诊断。跨重启场景通过 `pluginManager` 选入该组合包,再重启同一 profile。运行期场景须实际观察客户端就绪;`appReady` 不代表窗口已经显示。 + +检查原始错误与 cause 或退出码、跨重启的操作 ID 和 Host 运行 ID、报告收集完成标记与遗漏信息、原生弹窗中的文件获取,以及禁用插件恢复后的启动。记录安装包版本和源码提交;生成测试包成功或管理操作返回成功,都不能代替客户端功能验证。测试后恢复协议关联并退出测试进程。 + +资源验证可构造大型稀疏历史文件、大量无关文件、密集记录,以及损坏行和未完成行。在独立新进程中测量,分开记录数据构造与收集开销,并核对实际读取量、输出大小、耗时和峰值内存。验证 Worker 取消时,可用仅测试加载的模块钩子在写入进入标记后挂起异步文件打开操作;先确认标记,再观察期限到达后的 Worker 退出,并逐字节核对基本报告。该方法不模拟内核系统调用卡死,也不代替打包客户端验证。 ## 更新弹窗主题 diff --git a/apps/desktop/tests/expected/fatal-address-in-use-en.txt b/apps/desktop/tests/expected/fatal-address-in-use-en.txt index 1e9a9b62d0..fff3b98aec 100644 --- a/apps/desktop/tests/expected/fatal-address-in-use-en.txt +++ b/apps/desktop/tests/expected/fatal-address-in-use-en.txt @@ -1,5 +1,6 @@ DeepSeek Harness is unavailable The application could not start or stopped unexpectedly. Another DSH instance (such as dsh web or the desktop app) is running. They cannot start at the same time. Quit the other running DSH instance, then restart. +The diagnostic report could not be saved. You can provide a screenshot of this dialog when requesting support. Exit Restart diff --git a/apps/desktop/tests/expected/fatal-address-in-use-zh-CN.txt b/apps/desktop/tests/expected/fatal-address-in-use-zh-CN.txt index 30cad3d061..7c6e3dbc61 100644 --- a/apps/desktop/tests/expected/fatal-address-in-use-zh-CN.txt +++ b/apps/desktop/tests/expected/fatal-address-in-use-zh-CN.txt @@ -1,5 +1,6 @@ DeepSeek Harness 无法使用 应用无法启动或已意外停止。 有其他正在运行的 DSH(如其他 dsh web、桌面端),无法同时启动,请退出其他正在运行的 DSH 后重启。 +诊断报告未能保存,反馈问题时可提供此窗口的截图。 退出 重启 diff --git a/apps/desktop/tests/expected/fatal-dialog-en.txt b/apps/desktop/tests/expected/fatal-dialog-en.txt index 0dd0a62eb3..72cc858a1c 100644 --- a/apps/desktop/tests/expected/fatal-dialog-en.txt +++ b/apps/desktop/tests/expected/fatal-dialog-en.txt @@ -2,6 +2,7 @@ DeepSeek Harness is unavailable The application could not start or stopped unexpectedly. Desktop Host failed Plugin initialization failed +The diagnostic report could not be saved. You can provide a screenshot of this dialog when requesting support. If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. Exit diff --git a/apps/desktop/tests/expected/fatal-dialog-with-report-en.txt b/apps/desktop/tests/expected/fatal-dialog-with-report-en.txt index aaa637ca1a..89745ea803 100644 --- a/apps/desktop/tests/expected/fatal-dialog-with-report-en.txt +++ b/apps/desktop/tests/expected/fatal-dialog-with-report-en.txt @@ -8,3 +8,4 @@ If application files are missing or damaged, close the application and reinstall Exit Restart Disable third-party plugins, back up profile patch, and restart +Get diagnostic report diff --git a/apps/desktop/tests/expected/fatal-dialog-with-report-zh-CN.txt b/apps/desktop/tests/expected/fatal-dialog-with-report-zh-CN.txt index 353b59f4a4..610b96c509 100644 --- a/apps/desktop/tests/expected/fatal-dialog-with-report-zh-CN.txt +++ b/apps/desktop/tests/expected/fatal-dialog-with-report-zh-CN.txt @@ -8,3 +8,4 @@ Plugin initialization failed 退出 重启 禁用第三方插件、备份 profile patch 并重启 +获取诊断报告 diff --git a/apps/desktop/tests/expected/fatal-dialog-zh-CN.txt b/apps/desktop/tests/expected/fatal-dialog-zh-CN.txt index 6c40644c09..c8db153ae1 100644 --- a/apps/desktop/tests/expected/fatal-dialog-zh-CN.txt +++ b/apps/desktop/tests/expected/fatal-dialog-zh-CN.txt @@ -2,6 +2,7 @@ DeepSeek Harness 无法使用 应用无法启动或已意外停止。 Desktop Host failed Plugin initialization failed +诊断报告未能保存,反馈问题时可提供此窗口的截图。 如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 退出 diff --git a/apps/desktop/tests/expected/operation-history.txt b/apps/desktop/tests/expected/operation-history.txt new file mode 100644 index 0000000000..7ce5170211 --- /dev/null +++ b/apps/desktop/tests/expected/operation-history.txt @@ -0,0 +1,4 @@ + +--- recent plugin operations (context, not proof of cause) --- +{"journal":null,"bytesRead":260,"filesRead":1,"truncated":false,"invalidRecords":0,"unavailableFiles":0,"cancelled":false,"outputRecordsOmitted":0,"records":[{"runId":"c254ab28-97c9-4382-95e6-2db9c66f98c8","operation":{"id":"c254ab28-97c9-4382-95e6-2db9c66f98c9","time":"2026-10-19T00:00:00.000Z","reason":"bundle","target":"test-bundle","stage":"enable","phase":"started"},"transportDropped":0}]} +--- operation history collection complete; consult omission counters above --- diff --git a/apps/desktop/tests/fatal-recovery.spec.ts b/apps/desktop/tests/fatal-recovery.spec.ts index 7ab4efd68a..80f75c2cef 100644 --- a/apps/desktop/tests/fatal-recovery.spec.ts +++ b/apps/desktop/tests/fatal-recovery.spec.ts @@ -17,6 +17,7 @@ function fixture( disablePlugins: vi.fn(async () => {}), exit: vi.fn(), restart: vi.fn(), + revealReport: vi.fn(async (_path: string) => {}), writeReport: vi.fn(writeReport), } return { operations, choice, stopped, recovery: new DesktopFatalRecovery(operations) } @@ -31,14 +32,16 @@ const REPORT_PATH = 'C:\\Users\\someone\\AppData\\Roaming\\DeepSeek Harness\\log afterEach(() => { vi.restoreAllMocks() }) -it.each(['en', 'zh-CN'])('offers only exit and restart for a listener conflict in %s', async (locale) => { +it.each(['en', 'zh-CN'])('offers exit and restart for a listener conflict without a saved report in %s', async (locale) => { const { operations, choice, stopped, recovery } = fixture(locale) const pending = recovery.report(new AggregateError([ new Error('webserver (@deepseek-ai/dsh-host-webserver): Error: listen EADDRINUSE: address already in use 127.0.0.1:19387'), ], 'required startup failure'), 'host') await shown(operations) const options = operations.show.mock.calls[0]![0] - expect(options.buttons).toEqual([operations.messages().exitApplication, operations.messages().restartApplication]) + expect(options.buttons).toEqual([ + operations.messages().exitApplication, operations.messages().restartApplication, + ]) await expect([options.title, options.message, options.detail, ...options.buttons!].join('\n') + '\n') .toMatchFileSnapshot(`expected/fatal-address-in-use-${locale}.txt`) choice.resolve({ response: 1, checkboxChecked: false }) @@ -56,7 +59,7 @@ it.each(['win32', 'darwin', 'linux'] as const)('offers the same listener conflic expect(operations.show.mock.calls[0]![0].buttons).toEqual([ operations.messages().exitApplication, operations.messages().restartApplication, ]) - expect(operations.show.mock.calls[0]![0].detail).toBe(operations.messages().startupAddressInUse) + expect(operations.show.mock.calls[0]![0].detail).toBe(`${operations.messages().startupAddressInUse}\n${operations.messages().reportUnavailable}`) choice.resolve({ response: 0, checkboxChecked: false }) stopped.resolve(undefined) await pending @@ -188,7 +191,7 @@ it('names the report file in the listener-conflict dialog too', async () => { await shown(operations) await vi.waitFor(() => { expect(operations.show).toHaveBeenCalledOnce() }) const options = operations.show.mock.calls[0]![0] - expect(options.buttons).toHaveLength(2) + expect(options.buttons).toHaveLength(3) expect(options.detail).toBe(`${operations.messages().startupAddressInUse}\n${operations.messages().reportWrittenTo.replace('{path}', REPORT_PATH)}`) choice.resolve({ response: 0, checkboxChecked: false }) stopped.resolve(undefined) @@ -202,7 +205,7 @@ it('shows the dialog without a path when the report write fails or returns nothi const pending = recovery.report(new Error('fatal'), 'main') await shown(operations) await vi.waitFor(() => { expect(operations.show).toHaveBeenCalledOnce() }) - expect(operations.show.mock.calls[0]![0].detail).toBe(`fatal\n\n${operations.messages().startupReinstallAdvice}`) + expect(operations.show.mock.calls[0]![0].detail).toBe(`fatal\n${operations.messages().reportUnavailable}\n\n${operations.messages().startupReinstallAdvice}`) choice.resolve({ response: 0, checkboxChecked: false }) stopped.resolve(undefined) await pending @@ -239,3 +242,110 @@ it('writes the report once for the first fatal failure and not for the recovery- expect(operations.writeReport).toHaveBeenCalledOnce() expect(operations.show.mock.calls[1]![0].detail).toContain(REPORT_PATH) }) + +it('reveals the saved report without stopping the Host or restarting', async () => { + const { operations, stopped, recovery } = fixture('en', async () => REPORT_PATH) + operations.show.mockResolvedValueOnce({ response: 3, checkboxChecked: false }) + .mockImplementationOnce(async () => { + expect(operations.revealReport).toHaveBeenCalledWith(REPORT_PATH) + expect(operations.stop).not.toHaveBeenCalled() + expect(operations.restart).not.toHaveBeenCalled() + return { response: 0, checkboxChecked: false } + }) + stopped.resolve(undefined) + await recovery.report(new Error('fatal'), 'host') + expect(operations.writeReport).toHaveBeenCalledOnce() + expect(operations.show.mock.calls[1]![0].message).toBe(operations.messages().reportShareHint) +}) + +it('keeps the original failure and report path when revealing the file fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const { operations, stopped, recovery } = fixture('en', async () => REPORT_PATH) + operations.revealReport.mockRejectedValueOnce(new Error('file removed')) + operations.show.mockResolvedValueOnce({ response: 3, checkboxChecked: false }) + .mockResolvedValueOnce({ response: 0, checkboxChecked: false }) + stopped.resolve(undefined) + await recovery.report(new Error('original startup failure'), 'host') + expect(operations.show.mock.calls[1]![0]).toMatchObject({ message: operations.messages().reportRevealFailed }) + expect(operations.show.mock.calls[1]![0].detail).toContain('original startup failure') + expect(operations.show.mock.calls[1]![0].detail).toContain(REPORT_PATH) +}) + +it('makes a report that finishes after the initial deadline available without rewriting it', async () => { + vi.useFakeTimers() + try { + const write = Promise.withResolvers() + const { operations, choice, stopped, recovery } = fixture('en', () => write.promise) + const pending = recovery.report(new Error('fatal'), 'host') + await vi.advanceTimersByTimeAsync(CRASH_REPORT_WAIT_MS) + expect(operations.show.mock.calls[0]![0].detail).toContain(operations.messages().reportPending) + operations.show.mockResolvedValueOnce({ response: 0, checkboxChecked: false }) + write.resolve(REPORT_PATH) + choice.resolve({ response: 3, checkboxChecked: false }) + stopped.resolve(undefined) + await pending + expect(operations.revealReport).toHaveBeenCalledWith(REPORT_PATH) + expect(operations.writeReport).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } +}) + +it('does not attempt to reveal an unavailable report', async () => { + const { operations, stopped, recovery } = fixture() + operations.show.mockResolvedValueOnce({ response: 0, checkboxChecked: false }) + stopped.resolve(undefined) + await recovery.report(new Error('fatal'), 'host') + expect(operations.revealReport).not.toHaveBeenCalled() + expect(operations.show.mock.calls[0]![0].buttons).not.toContain(operations.messages().getDiagnosticReport) + expect(operations.show.mock.calls[0]![0].detail).toContain(operations.messages().reportUnavailable) +}) + +it.each(['pending', 'failed'] as const)('keeps the recovery failure visible when fetching a %s report', async (state) => { + vi.useFakeTimers() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const write = Promise.withResolvers() + try { + const { operations, stopped, recovery } = fixture('en', () => write.promise) + stopped.resolve(undefined) + operations.disablePlugins.mockRejectedValueOnce(new Error('profile is read-only')) + operations.show.mockResolvedValueOnce({ response: 2, checkboxChecked: false }) + .mockImplementationOnce(async () => { + if (state === 'failed') write.resolve(undefined) + return { response: 3, checkboxChecked: false } + }) + .mockResolvedValueOnce({ response: 0, checkboxChecked: false }) + const pending = recovery.report(new Error('original startup failure'), 'host') + await vi.runAllTimersAsync() + await pending + expect(operations.show).toHaveBeenCalledTimes(3) + const last = operations.show.mock.calls[2]![0] + expect(last.message).toBe(operations.messages().recoveryOperationFailed) + expect(last.detail).toContain('profile is read-only') + if (state === 'failed') expect(last.buttons).not.toContain(operations.messages().getDiagnosticReport) + expect(last.detail).toContain(state === 'pending' ? operations.messages().reportPending : operations.messages().reportUnavailable) + expect(operations.revealReport).not.toHaveBeenCalled() + expect(operations.writeReport).toHaveBeenCalledOnce() + } finally { + write.resolve(undefined) + vi.useRealTimers() + } +}) + +it.each([false, true])('keeps the original attachment when revealing after a recovery failure (reveal fails=%s)', async (revealFails) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const { operations, stopped, recovery } = fixture('en', async () => REPORT_PATH) + const original = new Error('original startup failure') + stopped.resolve(undefined) + operations.disablePlugins.mockRejectedValueOnce(new Error('profile is read-only')) + if (revealFails) operations.revealReport.mockRejectedValueOnce(new Error('file manager unavailable')) + operations.show.mockResolvedValueOnce({ response: 2, checkboxChecked: false }) + .mockResolvedValueOnce({ response: 3, checkboxChecked: false }) + .mockResolvedValueOnce({ response: 0, checkboxChecked: false }) + await recovery.report(original, 'host') + const last = operations.show.mock.calls[2]![0] + expect(last.message).toBe(revealFails ? operations.messages().reportRevealFailed : operations.messages().reportShareHint) + expect(last.detail).toContain('profile is read-only') + expect(operations.writeReport).toHaveBeenCalledExactlyOnceWith(original, 'host') + expect(operations.revealReport).toHaveBeenCalledExactlyOnceWith(REPORT_PATH) +}) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index 93b5f9bce2..9a72fd2925 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -1,8 +1,11 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError, QUIT_INSPECTION_DEADLINE_MS } from '../src/host-process.ts' +import { OperationJournal } from '../src/operation-journal.ts' +import { readOperationHistory } from '../src/operation-history.ts' +import { renderCrashReport } from '../src/crash-report.ts' const roots: string[] = [] const hosts: DesktopHostProcess[] = [] @@ -88,6 +91,85 @@ afterEach(async () => { }) describe('desktop host process', () => { + it('retains the announced run identity when startup fails before readiness', async () => { + const runtime = projectWithHost(` +process.send({ type: 'starting', runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8' }) +process.send({ type: 'fatal', message: 'profile startup failed', diagnostic: 'required plugin failed' }, () => process.disconnect()) +`) + const host = hostProcess(runtime) + const failure: unknown = await host.start().catch((error: unknown) => error) + await host.stop() + expect(failure).toBeInstanceOf(DesktopHostFatalError) + expect(failure).toHaveProperty('hostRunId', 'c254ab28-97c9-4382-95e6-2db9c66f98c8') + }) + + it.each([ + ['exception', 'queueMicrotask(() => { throw new Error("qualification-exception", { cause: new Error("qualification-runtime-cause") }) })'], + ['rejection', 'Promise.reject(new Error("qualification-rejection"))'], + ['exit', 'process.stderr.write("qualification-exit", () => process.exit(7))'], + ])('retains real child %s evidence in the final report after readiness', async (kind, trigger) => { + const failed = Promise.withResolvers() + const runtime = projectWithHost(` +process.send({ type: 'starting', runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8' }) +process.send({ type: 'ready', url: 'http://127.0.0.1:3080/' }, () => { ${trigger} }) +`) + const host = hostProcess(runtime, runtime, failed.resolve) + await host.start() + const failure = await failed.promise + await host.stop() + const report = renderCrashReport({ source: 'host', phase: 'running', error: failure, + rendererConsole: [], operationHistoryPlanned: true, time: new Date('2026-10-19T00:00:00Z'), + app: { name: 'qualification', version: 'test', platform: process.platform, arch: process.arch, + electron: 'not-running', node: process.version, locale: 'zh-CN' }, + }) + expect(report).toContain(`qualification-${kind}`) + expect(report).toContain('phase: running') + expect(report).toContain("hostRunId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8'") + if (kind === 'exception') expect(report).toContain('qualification-runtime-cause') + if (kind === 'exit') expect(report).toContain('7') + }) + + it('persists validated operation IPC across Host lifetimes and isolates a failing diagnostic sink', async () => { + const record = { + runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0, + operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z', + reason: 'bundle', phase: 'started', target: 'test-bundle', stage: 'enable' }, + secret: 'not-persisted', + } + const runtime = projectWithHost(` +process.send({ type: 'starting', runId: ${JSON.stringify(record.runId)} }) +process.send({ type: 'plugin-operation', record: { invalid: true } }) +process.send({ type: 'plugin-operation', record: { ...${JSON.stringify(record)}, runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c0' } }) +process.send({ type: 'plugin-operation', record: ${JSON.stringify(record)} }) +${HTTP_HOST}`) + const directory = join(runtime, 'operation-history') + const journal = new OperationJournal(directory, { recordBytes: 4096, segmentBytes: 8192, queuedBytes: 8192 }) + onTestFinished(async () => { await journal.close() }) + const sink = vi.fn(value => journal.enqueue(JSON.stringify(value))) + const first = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, + undefined, undefined, undefined, undefined, sink) + hosts.push(first) + await first.start() + await first.stop() + await journal.close() + expect(sink).toHaveBeenCalledTimes(1) + const saved = await readOperationHistory(directory, { bytes: 8192, recordBytes: 4096, records: 10 }, new AbortController().signal) + expect(saved.records).toEqual([{ runId: record.runId, transportDropped: 0, operation: record.operation }]) + + const warning = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const failure = vi.fn() + const second = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, + failure, undefined, undefined, undefined, () => { throw new Error('sink unavailable') }) + hosts.push(second) + await second.start() + expect(await second.updateTasks('inspect')).toBe(false) + await second.stop() + expect(failure).not.toHaveBeenCalled() + expect(warning).toHaveBeenCalledWith('desktop plugin operation diagnostic sink failed') + } finally { warning.mockRestore() } + }) + it('correlates authorized file resources and rejects Host denial over private IPC', async () => { const host = hostProcess(projectWithHost()) await host.start() diff --git a/apps/desktop/tests/locale-cache.spec.ts b/apps/desktop/tests/locale-cache.spec.ts new file mode 100644 index 0000000000..fdfeb4d557 --- /dev/null +++ b/apps/desktop/tests/locale-cache.spec.ts @@ -0,0 +1,62 @@ +import { SHIPPED_LOCALE_IDS } from '../../../scripts/shipped-locales.ts' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { readDesktopLocaleCache, writeDesktopLocaleCache } from '../src/locale-cache.ts' +import { resolveDesktopLocale, resolveDesktopStartupLocale } from '../src/locale.ts' + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'desktop-locale-')) + onTestFinished(() => { rmSync(directory, { recursive: true, force: true }) }) + return directory +} + +it('uses the confirmed Chinese language for recovery before Host settings are available', () => { + const directory = fixture() + expect(readDesktopLocaleCache(directory)).toBeUndefined() + writeDesktopLocaleCache(directory, 'zh-CN') + const cached = readDesktopLocaleCache(directory) + const locale = cached === undefined ? resolveDesktopStartupLocale(null, ['en-US']) : resolveDesktopLocale(cached) + expect(locale.messages.fatalSummary).toBe('应用无法启动或已意外停止。') + writeDesktopLocaleCache(directory, 'en') + expect(readDesktopLocaleCache(directory)).toBe('en') +}) + +it('uses the confirmed Japanese language for recovery before Host settings are available', () => { + const directory = fixture() + writeDesktopLocaleCache(directory, 'ja') + const cached = readDesktopLocaleCache(directory) + const locale = cached === undefined ? resolveDesktopStartupLocale(null, ['en-US']) : resolveDesktopLocale(cached) + expect(locale.id).toBe('ja') + expect(locale.messages.getDiagnosticReport).toBe('診断レポートを取得') +}) + +it.each(SHIPPED_LOCALE_IDS)('restores the confirmed shipped language %s before Host settings are available', (id) => { + const directory = fixture() + const selected = resolveDesktopLocale(id) + writeDesktopLocaleCache(directory, selected.id) + const cached = readDesktopLocaleCache(directory) + expect(cached).toBe(selected.id) + expect(resolveDesktopLocale(cached!).messages.getDiagnosticReport).toBe(selected.messages.getDiagnosticReport) +}) + +it.each(['', 'it', '{invalid'])('falls back to system language for an invalid cache: %s', (value) => { + const directory = fixture() + writeDesktopLocaleCache(directory, 'en') + writeFileSync(join(directory, 'desktop', 'locale-cache'), value) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + expect(readDesktopLocaleCache(directory)).toBeUndefined() + expect(warn).toHaveBeenCalled() +}) + +it('keeps read and write failures from blocking recovery', () => { + const directory = fixture() + writeFileSync(join(directory, 'desktop'), 'not a directory') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + expect(() => { writeDesktopLocaleCache(directory, 'zh-CN') }).not.toThrow() + expect(readDesktopLocaleCache(directory)).toBeUndefined() + expect(warn).toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index b2d925af6c..20325642a2 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1,9 +1,11 @@ +import { parsePluginOperationRecord } from '@deepseek-ai/dsh-plugin-manager/diagnostic' +import type { DesktopHostProcess } from '../src/host-process.ts' import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts' import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { IpcMainInvokeEvent } from 'electron' import { join } from 'node:path' -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import type { MenuItemConstructorOptions, MessageBoxOptions } from 'electron' import { DESKTOP_IPC, type DesktopUpdateState } from '../src/ipc.ts' @@ -12,6 +14,7 @@ import { DesktopHostFatalError, DesktopHostUncleanExitError } from '../src/host- import { en, zh } from '../src/locale.ts' import { DesktopUpdatePreparationError } from '../src/update-error.ts' import { writeCrashReport } from '../src/crash-report.ts' +import { runOperationReportJob } from '../src/operation-report-job.ts' type InvokeEvent = { sender?: unknown; senderFrame: { url: string } } type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown @@ -20,11 +23,12 @@ const fileDrag = vi.hoisted(() => ({ dispose: vi.fn<() => Promise>(), inst vi.mock('../src/file-drag.ts', () => ({ installDesktopFileDrag: () => { fileDrag.install(); return fileDrag.dispose } })) vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() })) +vi.mock('../src/operation-report-job.ts', () => ({ runOperationReportJob: vi.fn(async () => ({ appended: false, timedOut: false, cancelled: false })) })) // Report persistence has its own unit tests; here it resolves within microtasks so the fatal // dialog never outlives the test that triggered it. vi.mock('../src/crash-report.ts', async importOriginal => ({ ...await importOriginal(), - writeCrashReport: vi.fn(async () => 'desktop-test-logs/crash-test.log'), + writeCrashReport: vi.fn<(...args: unknown[]) => Promise>(), pruneCrashReports: vi.fn(async () => {}), })) @@ -164,6 +168,8 @@ const harness = await vi.hoisted(async () => { readonly inspectPort?: number, readonly environment?: NodeJS.ProcessEnv, readonly onFailure?: (error: Error) => void, readonly primaryRuntime?: string, readonly packageManager?: { pnpm: string; nodeBin: string }, + readonly onPlatformSession?: ConstructorParameters[8], + readonly onPluginOperation?: ConstructorParameters[9], ) { hosts.push(this) } } const app = Object.assign(new EventEmitter(), { @@ -427,9 +433,10 @@ beforeEach(() => { harness.reset() const userData = mkdtempSync(join(tmpdir(), 'dsh-main-user-data-')) onTestFinished(() => { rmSync(userData, { recursive: true, force: true }) }) - harness.app.getPath.mockImplementation(name => name === 'userData' ? userData : `desktop-test-${name}`) + harness.app.getPath.mockImplementation(name => name === 'userData' ? userData : join(userData, `desktop-test-${name}`)) + vi.mocked(writeCrashReport).mockResolvedValue(join(harness.app.getPath('logs'), 'crash-test.log')) harness.dialog.showMessageBox.mockImplementation((options: { title?: string }) => { - if (options.title !== en.startupFailed) return Promise.resolve({ response: 1 }) + if (options.title !== en.startupFailed && options.title !== zh.startupFailed) return Promise.resolve({ response: 1 }) harness.dialogShown.resolve() return new Promise(() => {}) }) @@ -954,7 +961,7 @@ describe('desktop main startup', () => { expect(window.setBackgroundColor).not.toHaveBeenCalled() }) - it('follows the Windows primary document language and palette without trusting other frames', async () => { + it('follows confirmed language and primary document palette without trusting other frames', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') await import('../src/main.ts') await harness.preparing.promise @@ -963,10 +970,12 @@ describe('desktop main startup', () => { const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } listener({ ...event, senderFrame: { url: 'dsh-app://app/' } }, 'zh-CN', '#ffffff', '#000000') expect(window.setTitleBarOverlay).not.toHaveBeenCalled() + harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.localeChanged)![1](event, 'zh') listener(event, 'zh-CN', 'rgb(249, 250, 251)', '#0f1115') expect(window.setTitleBarOverlay).toHaveBeenCalledWith({ color: 'rgb(249, 250, 251)', symbolColor: '#0f1115' }) window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags: { canCopy: true } }) expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: '复制', accelerator: '' }]) + harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.localeChanged)![1](event, 'en') listener(event, 'en', '#1b1b1c', '#f9fafb') window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags: { canCopy: true } }) expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: 'Copy', accelerator: '' }]) @@ -979,7 +988,30 @@ describe('desktop main startup', () => { window.webContents.mainFrame.url = 'dsh-app://unowned/index.html' listener(event, 'zh-CN', '#fff', '#000') expect(window.setTitleBarOverlay).not.toHaveBeenCalled() - expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce() + expect(harness.menu.setApplicationMenu).toHaveBeenCalledTimes(3) + }) + + it('retains cached Chinese for a Host startup crash after the loading page reports English', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const directory = join(harness.app.getPath('userData'), 'desktop') + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'locale-cache'), 'zh-CN\n') + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + const appearance = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.windowsAppearance)![1] + appearance({ sender: window.webContents, senderFrame: window.webContents.mainFrame }, 'en', '#fff', '#000') + harness.prepared.resolve() + await harness.hostStarted.promise + const host = harness.hosts[0]! + const failure = new DesktopHostFatalError('test plugin failed', 'missing required services') + host.onFailure!(failure) + host.ready.reject(failure) + await host.stopping.promise + host.exited.resolve() + await harness.dialogShown.promise + expect(vi.mocked(writeCrashReport).mock.calls[0]![1].app.locale).toBe('zh-CN') + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).message).toBe(zh.fatalSummary) }) it('maps Windows caption menus to localized native commands and rejects foreign popup requests', async () => { @@ -989,6 +1021,7 @@ describe('desktop main startup', () => { const window = harness.windows[0]! const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } const appearance = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.windowsAppearance)![1] + harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.localeChanged)![1](event, 'zh') appearance(event, 'zh-CN', '#fff', '#000') const handler = harness.handlers.get(DESKTOP_IPC.windowsMenu)! const foreignEvent = { ...event, sender: {} } @@ -2206,8 +2239,8 @@ describe('desktop main startup', () => { handler(event, 'client mount failed') await harness.dialogShown.promise expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('client mount failed') - expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('desktop-test-logs/crash-test.log') - expect(writeCrashReport).toHaveBeenCalledWith('desktop-test-logs', expect.objectContaining({ source: 'web-boot', phase: 'startup' })) + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain(join(harness.app.getPath('logs'), 'crash-test.log')) + expect(writeCrashReport).toHaveBeenCalledWith(harness.app.getPath('logs'), expect.objectContaining({ source: 'web-boot', phase: 'startup' })) expect(window.urls).toEqual(['dsh-app://app/']) }) @@ -2221,7 +2254,7 @@ describe('desktop main startup', () => { window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/', true) await harness.dialogShown.promise expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('Desktop page failed to load') - expect(writeCrashReport).toHaveBeenCalledWith('desktop-test-logs', expect.objectContaining({ source: 'renderer' })) + expect(writeCrashReport).toHaveBeenCalledWith(harness.app.getPath('logs'), expect.objectContaining({ source: 'renderer' })) }) it('offers all recovery choices when resources fail before the Host starts', async () => { @@ -2230,7 +2263,7 @@ describe('desktop main startup', () => { harness.prepared.reject(new Error('runtime resources missing')) await harness.dialogShown.promise expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('runtime resources missing') - expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up profile patch, and restart']) + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up profile patch, and restart', 'Get diagnostic report']) expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/']) }) @@ -2270,6 +2303,20 @@ describe('desktop main startup', () => { expect(harness.hosts).toHaveLength(0) }) + it('writes operation history under the instance userData instead of the shared logs directory', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + const record = parsePluginOperationRecord({ runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0, + operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-20T00:00:00.000Z', + reason: 'bundle', phase: 'started', target: 'test', stage: 'enable' } })! + harness.hosts[0]!.onPluginOperation!(record) + const file = join(harness.app.getPath('userData'), 'plugin-operations', 'operations.jsonl') + await vi.waitFor(() => { expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(record) }) + expect(existsSync(join(harness.app.getPath('logs'), 'plugin-operations'))).toBe(false) + }) + it('writes the Host\'s own diagnostic into a startup-phase crash report when the Host reports a fatal error before ready', async () => { await import('../src/main.ts') await harness.preparing.promise @@ -2285,11 +2332,15 @@ describe('desktop main startup', () => { host.exited.resolve() await harness.dialogShown.promise const [directory, report] = vi.mocked(writeCrashReport).mock.calls[0]! - expect(directory).toBe('desktop-test-logs') + expect(directory).toBe(harness.app.getPath('logs')) expect(report.source).toBe('host') expect(report.phase).toBe('startup') expect(report.error).toBeInstanceOf(DesktopHostFatalError) expect(report.hostDiagnostic).toBe(diagnostic) + expect(runOperationReportJob).toHaveBeenCalledWith({ + reportPath: join(harness.app.getPath('logs'), 'crash-test.log'), directory: join(harness.app.getPath('userData'), 'plugin-operations'), + limits: { bytes: 65536, recordBytes: 4096, records: 100, outputBytes: 65536 }, journal: null, + }, { timeoutMs: 500, cancellationGraceMs: 100 }, expect.any(AbortSignal)) expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('profile has no cordis.yml') }) @@ -2312,7 +2363,7 @@ describe('desktop main startup', () => { await host.stopping.promise expect(writeCrashReport).toHaveBeenCalledOnce() const [directory, report] = vi.mocked(writeCrashReport).mock.calls[0]! - expect(directory).toBe('desktop-test-logs') + expect(directory).toBe(harness.app.getPath('logs')) expect(report.source).toBe('host') expect(report.phase).toBe('running') expect(report.error).toBeInstanceOf(Error) @@ -2324,7 +2375,76 @@ describe('desktop main startup', () => { await harness.quitCompleted.promise }) - it.each([0, 1, 2])('waits for Host exit before recovery action %s', async (response) => { + it.each([{ rejectCleanup: false, stuck: false }, { rejectCleanup: true, stuck: false }, + { rejectCleanup: true, stuck: true }])('waits for the diagnostic Worker (policy rejects=$rejectCleanup, stuck=$stuck)', async ({ rejectCleanup, stuck }) => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', + allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => + Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))) + let finishReport!: () => void + let reportSignal: AbortSignal | undefined + let workerExited = false + const report = new Promise<{ appended: boolean; timedOut: boolean; cancelled: boolean }>((resolve) => { + finishReport = () => { workerExited = true; resolve({ appended: false, timedOut: false, cancelled: true }) } + }) + vi.mocked(runOperationReportJob).mockImplementationOnce((_input, _options, signal) => { + reportSignal = signal + return report + }) + let finishPolicy!: () => void + let failPolicy!: (error: Error) => void + const policy = new Promise((resolve, reject) => { finishPolicy = resolve; failPolicy = reject }) + testAuth.dispose.mockImplementationOnce(() => policy) + const failure = new Error('policy Session storage cleanup failed') + const host = await readyForUpdate() + let quitCompleted = false + void harness.quitCompleted.promise.then(() => { quitCompleted = true }) + try { + const window = harness.windows[0]! + window.webContents.emit('render-process-gone', {}, { reason: 'crashed' }) + await harness.dialogShown.promise + expect(runOperationReportJob).toHaveBeenCalledOnce() + host.exited.resolve() + window.emit('session-end', { reasons: ['shutdown'] }) + harness.app.quit() + await vi.advanceTimersByTimeAsync(0) + expect(testAuth.dispose).toHaveBeenCalledOnce() + expect(reportSignal?.aborted).toBe(true) + expect(quitCompleted).toBe(false) + if (rejectCleanup) failPolicy(failure) + else finishPolicy() + await vi.advanceTimersByTimeAsync(0) + if (rejectCleanup) expect(console.error).toHaveBeenCalledWith(failure) + expect(workerExited).toBe(false) + expect(quitCompleted).toBe(false) + if (stuck) { + await vi.advanceTimersByTimeAsync(499) + expect(quitCompleted).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(workerExited).toBe(false) + expect(console.error).toHaveBeenCalledWith('dsh desktop: operation report Worker has not exited; continuing shutdown') + } else { + finishReport() + await vi.advanceTimersByTimeAsync(0) + } + expect(quitCompleted).toBe(true) + } finally { + finishPolicy() + finishReport() + host.exited.resolve() + await vi.advanceTimersByTimeAsync(0) + await harness.quitCompleted.promise + } + }) + + it.each([{ response: 0, stuck: false }, { response: 1, stuck: false }, { response: 2, stuck: false }, + { response: 0, stuck: true }])('waits for Host exit and bounds Worker cleanup for recovery $response (stuck=$stuck)', async ({ response, stuck }) => { + let finishReport!: (result: { appended: boolean; timedOut: boolean; cancelled: boolean }) => void + let reportSignal: AbortSignal | undefined + vi.mocked(runOperationReportJob).mockImplementationOnce(async (_input, _options, signal) => { + reportSignal = signal + return new Promise((resolve) => { finishReport = resolve }) + }) harness.dialog.showMessageBox.mockResolvedValue({ response, checkboxChecked: false }) await import('../src/main.ts') await harness.preparing.promise @@ -2338,7 +2458,16 @@ describe('desktop main startup', () => { expect(harness.app.relaunch).not.toHaveBeenCalled() expect(harness.disableAllPlugins).not.toHaveBeenCalled() host.exited.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(reportSignal?.aborted).toBe(true) + expect(harness.app.quit).not.toHaveBeenCalled() + if (stuck) await vi.advanceTimersByTimeAsync(500) + else finishReport({ appended: false, timedOut: false, cancelled: true }) await harness.quitCompleted.promise + if (stuck) { + expect(console.error).toHaveBeenCalledWith('dsh desktop: operation report Worker has not exited; continuing shutdown') + finishReport({ appended: false, timedOut: false, cancelled: true }) + } expect(harness.app.relaunch).toHaveBeenCalledTimes(response === 0 ? 0 : 1) expect(harness.disableAllPlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0) if (response === 2) { diff --git a/apps/desktop/tests/operation-history.spec.ts b/apps/desktop/tests/operation-history.spec.ts new file mode 100644 index 0000000000..237287d5b0 --- /dev/null +++ b/apps/desktop/tests/operation-history.spec.ts @@ -0,0 +1,172 @@ +import { mkdtemp, mkdir, open, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { readOperationHistory } from '../src/operation-history.ts' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, open: vi.fn(actual.open) } +}) + +const limits = { bytes: 64 * 1024, recordBytes: 2048, records: 100 } +function record(id: string | number) { + return { runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0, + operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z', + reason: 'plugin', phase: 'started', target: String(id), stage: 'enable' } } +} +function line(id: string | number) { return JSON.stringify(record(id)) + '\n' } + +async function fixture() { + const path = await mkdtemp(join(tmpdir(), 'desktop-operation-history-')) + onTestFinished(async () => { await rm(path, { recursive: true, force: true }) }) + return path +} + +it('collects recent complete records and reports malformed and incomplete entries', async () => { + const path = await fixture() + await writeFile(join(path, 'operations.jsonl'), line(1) + '{broken}\n' + line(2) + '{"incomplete":') + await writeFile(join(path, 'operations.previous.jsonl'), line(0)) + const result = await readOperationHistory(path, limits, new AbortController().signal) + expect(result.records).toEqual([record(2), record(1), record(0)]) + expect(result).toMatchObject({ invalidRecords: 1, truncated: true, cancelled: false, unavailableFiles: 0 }) +}) + +it('reads no more than its byte budget from a two GiB file and ignores unrelated history', async () => { + const path = await fixture() + await using large = await open(join(path, 'operations.jsonl'), 'w') + const size = 2 * 1024 * 1024 * 1024 + await large.truncate(size) + const tail = Buffer.from('\n' + line('last')) + await large.write(tail, 0, tail.length, size - tail.length) + await writeFile(join(path, 'pnpm.log'), 'unrelated output') + const result = await readOperationHistory(path, limits, new AbortController().signal) + expect(result.records).toEqual([record('last')]) + expect(result.bytesRead).toBe(limits.bytes) + expect(result.truncated).toBe(true) +}) + +it('bounds parsed record size and output count independently', async () => { + const path = await fixture() + await writeFile(join(path, 'operations.jsonl'), line(1) + '{"large":"' + 'x'.repeat(4096) + '"}\n' + line(2)) + const result = await readOperationHistory(path, { ...limits, records: 2 }, new AbortController().signal) + expect(result.records).toEqual([record(2), record(1)]) + expect(result.invalidRecords).toBe(1) +}) + +it('returns immediately without I/O when already cancelled', async () => { + const result = await readOperationHistory('not-a-directory', limits, AbortSignal.abort()) + expect(result).toMatchObject({ records: [], bytesRead: 0, cancelled: true, unavailableFiles: 0 }) +}) + +it('reports unreadable segments without failing the whole collection', async () => { + const path = await fixture() + await mkdir(join(path, 'operations.jsonl')) + await writeFile(join(path, 'operations.previous.jsonl'), line(1)) + const result = await readOperationHistory(path, limits, new AbortController().signal) + expect(result.records).toEqual([record(1)]) + expect(result.unavailableFiles).toBe(1) +}) + +it('awaits an in-flight read before closing and schedules no further reads after cancellation', async () => { + const path = await fixture() + const filename = join(path, 'operations.jsonl') + await writeFile(filename, ' '.repeat(limits.bytes)) + const handle = await open(filename, 'r') + onTestFinished(async () => { await handle.close() }) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const opener = vi.mocked(open).mockClear().mockResolvedValueOnce(handle) + const reader = vi.spyOn(handle, 'read').mockImplementationOnce(async () => { + started.resolve(undefined) + await release.promise + return { bytesRead: 1, buffer: Buffer.alloc(1) } + }) + const controller = new AbortController() + let settled = false + const collecting = readOperationHistory(path, limits, controller.signal) + .then((result) => { settled = true; return result }) + try { + await started.promise + controller.abort() + await Promise.resolve(undefined) + expect(settled).toBe(false) + expect(handle.fd).not.toBe(-1) + release.resolve(undefined) + expect(await collecting).toMatchObject({ cancelled: true, bytesRead: 1, records: [] }) + expect(handle.fd).toBe(-1) + expect(reader).toHaveBeenCalledTimes(1) + expect(opener).toHaveBeenCalledTimes(1) + } finally { + release.resolve(undefined) + await collecting + reader.mockRestore() + opener.mockClear() + await handle.close() + } +}) + +it('contains truncation between stat and read and still collects the previous segment', async () => { + const path = await fixture() + const filename = join(path, 'operations.jsonl') + await writeFile(filename, line(2)) + await writeFile(join(path, 'operations.previous.jsonl'), line(1)) + const handle = await open(filename, 'r+') + onTestFinished(async () => { await handle.close() }) + const originalStat = await handle.stat() + const stat = vi.spyOn(handle, 'stat').mockImplementationOnce(async () => { + await handle.truncate(0) + return originalStat + }) + const opener = vi.mocked(open).mockClear().mockResolvedValueOnce(handle) + try { + const result = await readOperationHistory(path, limits, new AbortController().signal) + expect(result).toMatchObject({ records: [record(1)], truncated: true, unavailableFiles: 0 }) + expect(handle.fd).toBe(-1) + } finally { + stat.mockRestore() + opener.mockClear() + await handle.close() + } +}) + +it('rejects invalid resource budgets before opening a file', async () => { + for (const bytes of [0, -1, 0.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + await expect(readOperationHistory('not-a-directory', { ...limits, bytes }, new AbortController().signal)) + .rejects.toThrow(RangeError) + } +}) + +it('does not collect the same open segment twice when it rotates during collection', async () => { + const path = await fixture() + const current = join(path, 'operations.jsonl') + await writeFile(current, line(1)) + const handle = await open(current, 'r') + onTestFinished(async () => { await handle.close() }) + const opener = vi.mocked(open).mockImplementationOnce(async () => { + await rename(current, join(path, 'operations.previous.jsonl')) + await writeFile(current, line(2)) + return handle + }) + try { + const result = await readOperationHistory(path, limits, new AbortController().signal) + expect(result.records).toEqual([record(1)]) + expect(result).toMatchObject({ filesRead: 1, truncated: true }) + expect(handle.fd).toBe(-1) + } finally { + opener.mockClear() + await handle.close() + } +}) + +it.each(['absent', 'empty', 'nonempty'])('reports omitted bytes at an exact budget: previous segment %s', async (previous) => { + const path = await fixture() + const current = line(1) + await writeFile(join(path, 'operations.jsonl'), current) + if (previous !== 'absent') await writeFile(join(path, 'operations.previous.jsonl'), previous === 'empty' ? '' : line(0)) + const result = await readOperationHistory(path, + { ...limits, bytes: Buffer.byteLength(current), records: 1 }, new AbortController().signal) + expect(result.records).toEqual([record(1)]) + expect(result.truncated).toBe(previous === 'nonempty') + expect(result.filesRead).toBe(1) +}) diff --git a/apps/desktop/tests/operation-journal.spec.ts b/apps/desktop/tests/operation-journal.spec.ts new file mode 100644 index 0000000000..98e97d36a5 --- /dev/null +++ b/apps/desktop/tests/operation-journal.spec.ts @@ -0,0 +1,115 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { OperationJournal } from '../src/operation-journal.ts' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, mkdir: vi.fn(actual.mkdir) } +}) + +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'desktop-operation-journal-')) + onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) }) + return directory +} + +it('retains two bounded segments after dense operations across 50 plugins', async () => { + const directory = await fixture() + const journal = new OperationJournal(directory, { recordBytes: 128, segmentBytes: 1024, queuedBytes: 128 * 1000 }) + onTestFinished(async () => { await journal.close() }) + for (let i = 0; i < 1000; i++) expect(journal.enqueue(JSON.stringify({ plugin: i % 50, operation: i }))).toBe(true) + await journal.close() + expect((await readdir(directory)).sort()).toEqual(['operations.jsonl', 'operations.previous.jsonl']) + for (const name of await readdir(directory)) { + expect((await stat(join(directory, name))).size).toBeLessThanOrEqual(1024) + for (const line of (await readFile(join(directory, name), 'utf8')).trim().split('\n')) expect(() => { JSON.parse(line) }).not.toThrow() + } + expect(await readFile(join(directory, 'operations.jsonl'), 'utf8')).toContain('"operation":999') + expect(journal.status()).toEqual({ dropped: 0, failed: 0, queuedBytes: 0, disabled: false, repairedSegments: 0 }) +}) + +it('bounds the queue and encoded record bytes including multibyte characters and newline', async () => { + const directory = await fixture() + const journal = new OperationJournal(directory, { recordBytes: 8, segmentBytes: 32, queuedBytes: 16 }) + onTestFinished(async () => { await journal.close() }) + expect(journal.enqueue('{"中":1}')).toBe(false) + expect(journal.enqueue('{"a":1}')).toBe(true) + expect(journal.enqueue('{"a":2}')).toBe(true) + expect(journal.enqueue('{"a":3}')).toBe(false) + expect(journal.status()).toMatchObject({ queuedBytes: 16, dropped: 2 }) + await journal.close() + expect(await readFile(join(directory, 'operations.jsonl'), 'utf8')).toBe('{"a":1}\n{"a":2}\n') + expect(journal.enqueue('{}')).toBe(false) +}) + +it('disables persistence after a write failure without rejecting producers or close', async () => { + const directory = await fixture() + const blocked = join(directory, 'file') + await writeFile(blocked, 'untouched') + const journal = new OperationJournal(blocked, { recordBytes: 32, segmentBytes: 64, queuedBytes: 64 }) + expect(journal.enqueue('{"operation":1}')).toBe(true) + await expect(journal.close()).resolves.toBeUndefined() + expect(journal.status()).toEqual({ dropped: 0, failed: 1, queuedBytes: 0, disabled: true, repairedSegments: 0 }) + expect(await readFile(blocked, 'utf8')).toBe('untouched') +}) + +it.each(['', '{"old":1}\n'])('preserves both segments when repairing a partial append after %j', async (prefix) => { + const directory = await fixture() + const previous = '{"previous":0}\n' + await writeFile(join(directory, 'operations.previous.jsonl'), previous) + await writeFile(join(directory, 'operations.jsonl'), prefix + '{"partial":') + const journal = new OperationJournal(directory, { recordBytes: 32, segmentBytes: 64, queuedBytes: 64 }) + expect(journal.enqueue('{"new":2}')).toBe(true) + await journal.close() + expect(await readFile(join(directory, 'operations.jsonl'), 'utf8')).toBe(prefix + '{"new":2}\n') + expect(await readFile(join(directory, 'operations.previous.jsonl'), 'utf8')).toBe(previous) + expect(journal.status()).toMatchObject({ repairedSegments: 1, disabled: false }) +}) + +it('bounds both pre-existing oversized segments before writing new records', async () => { + const directory = await fixture() + for (const name of ['operations.jsonl', 'operations.previous.jsonl']) await writeFile(join(directory, name), 'x'.repeat(1000)) + const journal = new OperationJournal(directory, { recordBytes: 32, segmentBytes: 64, queuedBytes: 64 }) + journal.enqueue('{"new":2}') + await journal.close() + for (const name of await readdir(directory)) expect((await stat(join(directory, name))).size).toBeLessThanOrEqual(64) + expect(journal.status()).toMatchObject({ repairedSegments: 2, disabled: false }) +}) + +it('stops scheduling writes after a drain deadline without claiming the in-flight request ended', async () => { + const directory = await fixture() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + vi.mocked(mkdir).mockImplementationOnce(async () => { entered.resolve(undefined); await release.promise; return undefined }) + const journal = new OperationJournal(directory, { recordBytes: 32, segmentBytes: 64, queuedBytes: 64 }) + onTestFinished(async () => { release.resolve(undefined); await journal.close(); vi.useRealTimers() }) + journal.enqueue('{"operation":1}') + await entered.promise + vi.useFakeTimers() + const draining = journal.drain(500) + await vi.advanceTimersByTimeAsync(500) + expect(await draining).toBe(false) + expect(journal.status()).toMatchObject({ disabled: true, queuedBytes: 16 }) + expect(journal.enqueue('{"operation":2}')).toBe(false) + let repeated: boolean | undefined + void journal.drain(500).then((result) => { repeated = result }) + await vi.advanceTimersByTimeAsync(0) + expect(repeated).toBe(false) + release.resolve(undefined) + await journal.close() + expect(await readdir(directory)).toEqual([]) + expect(journal.status()).toMatchObject({ failed: 1, queuedBytes: 0 }) +}) + +it('can keep recording after successful installer preparation when the installer later fails', async () => { + const directory = await fixture() + const journal = new OperationJournal(directory, { recordBytes: 32, segmentBytes: 64, queuedBytes: 64 }) + onTestFinished(async () => { await journal.close() }) + journal.enqueue('{"operation":1}') + expect(await journal.drain(1000)).toBe(true) + expect(journal.enqueue('{"operation":2}')).toBe(true) + await journal.close() + expect(await readFile(join(directory, 'operations.jsonl'), 'utf8')).toBe('{"operation":1}\n{"operation":2}\n') +}) diff --git a/apps/desktop/tests/operation-report-job.spec.ts b/apps/desktop/tests/operation-report-job.spec.ts new file mode 100644 index 0000000000..82ea02fcd8 --- /dev/null +++ b/apps/desktop/tests/operation-report-job.spec.ts @@ -0,0 +1,103 @@ +import { EventEmitter } from 'node:events' +import { afterEach, expect, it, vi } from 'vitest' +import { runOperationReportJob } from '../src/operation-report-job.ts' + +const create = vi.hoisted(() => vi.fn<(...args: unknown[]) => EventEmitter>()) +vi.mock('node:worker_threads', () => ({ Worker: function (...args: unknown[]) { return create(...args) } })) +afterEach(() => { vi.useRealTimers(); create.mockReset() }) +const input = { reportPath: 'saved.log', directory: 'history', + limits: { bytes: 65536, recordBytes: 4096, records: 100, outputBytes: 65536 }, journal: null } + +it('does not report cancellation complete until the terminated worker exits', async () => { + vi.useFakeTimers() + const worker = Object.assign(new EventEmitter(), { postMessage: vi.fn(), terminate: vi.fn(async () => 1) }) + create.mockReturnValue(worker) + let settled = false + const result = runOperationReportJob(input, { timeoutMs: 500, cancellationGraceMs: 100 }, new AbortController().signal).then((value) => { + settled = true + return value + }) + await vi.advanceTimersByTimeAsync(500) + expect(worker.terminate).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + worker.emit('exit', 1) + expect(await result).toEqual({ appended: false, timedOut: true, cancelled: true }) + expect(vi.getTimerCount()).toBe(0) +}) + +it('requires successful worker exit after the complete append message', async () => { + vi.useFakeTimers() + const worker = new EventEmitter() + create.mockReturnValue(worker) + const result = runOperationReportJob(input, { timeoutMs: 500, cancellationGraceMs: 100 }, new AbortController().signal) + worker.emit('message', true) + worker.emit('exit', 0) + expect(await result).toEqual({ appended: true, timedOut: false, cancelled: false }) + expect(vi.getTimerCount()).toBe(0) +}) + +it('contains startup and runtime worker failures', async () => { + create.mockImplementationOnce(() => { throw new Error('worker unavailable') }) + expect(await runOperationReportJob(input, { timeoutMs: 500, cancellationGraceMs: 100 }, new AbortController().signal)) + .toEqual({ appended: false, timedOut: false, cancelled: false }) + const worker = new EventEmitter() + create.mockReturnValue(worker) + const result = runOperationReportJob(input, { timeoutMs: 500, cancellationGraceMs: 100 }, new AbortController().signal) + worker.emit('error', new Error('collector failed')) + worker.emit('exit', 1) + expect(await result).toEqual({ appended: false, timedOut: false, cancelled: false }) +}) + +it('retains ownership after termination rejects until the worker actually exits', async () => { + vi.useFakeTimers() + const worker = Object.assign(new EventEmitter(), { postMessage: vi.fn(), terminate: vi.fn(async () => { throw new Error('termination failed') }) }) + create.mockReturnValue(worker) + let settled = false + const result = runOperationReportJob(input, { timeoutMs: 500, cancellationGraceMs: 100 }, new AbortController().signal).then((value) => { + settled = true + return value + }) + await vi.advanceTimersByTimeAsync(500) + expect(worker.terminate).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + worker.emit('exit', 1) + expect(await result).toEqual({ appended: false, timedOut: true, cancelled: true }) + expect(vi.getTimerCount()).toBe(0) +}) + + +it('allows cooperative exit during the reserved cleanup window', async () => { + vi.useFakeTimers() + const worker = Object.assign(new EventEmitter(), { postMessage: vi.fn(), terminate: vi.fn() }) + create.mockReturnValue(worker) + const result = runOperationReportJob(input, { timeoutMs: 500, cancellationGraceMs: 100 }, new AbortController().signal) + await vi.advanceTimersByTimeAsync(400) + expect(worker.postMessage).toHaveBeenCalledExactlyOnceWith('cancel') + expect(worker.terminate).not.toHaveBeenCalled() + worker.emit('exit', 0) + expect(await result).toEqual({ appended: false, timedOut: true, cancelled: true }) + expect(vi.getTimerCount()).toBe(0) +}) + +it('cancels on application shutdown without extending the cleanup window', async () => { + vi.useFakeTimers() + const worker = Object.assign(new EventEmitter(), { postMessage: vi.fn(), terminate: vi.fn(async () => 1) }) + create.mockReturnValue(worker) + const controller = new AbortController() + const result = runOperationReportJob(input, { timeoutMs: 500, cancellationGraceMs: 100 }, controller.signal) + await vi.advanceTimersByTimeAsync(200) + controller.abort() + controller.abort() + expect(worker.postMessage).toHaveBeenCalledExactlyOnceWith('cancel') + await vi.advanceTimersByTimeAsync(100) + expect(worker.terminate).toHaveBeenCalledTimes(1) + worker.emit('exit', 1) + expect(await result).toEqual({ appended: false, timedOut: false, cancelled: true }) + expect(vi.getTimerCount()).toBe(0) +}) + +it('does not start a collector after shutdown was requested', async () => { + expect(await runOperationReportJob(input, { timeoutMs: 500, cancellationGraceMs: 100 }, AbortSignal.abort())) + .toEqual({ appended: false, timedOut: false, cancelled: true }) + expect(create).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/operation-report-worker.e2e.ts b/apps/desktop/tests/operation-report-worker.e2e.ts new file mode 100644 index 0000000000..fd89dd11da --- /dev/null +++ b/apps/desktop/tests/operation-report-worker.e2e.ts @@ -0,0 +1,81 @@ +/** Built job and packaged Worker acceptance without provider credentials. */ +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' +import { expect, it, onTestFinished } from 'vitest' + +const job = new URL('../lib/types/operation-report-job.js', import.meta.url) +const worker = new URL('../lib/operation-report-worker.js', import.meta.url) + +it.skipIf(!existsSync(new URL('../lib/types/', import.meta.url)))('appends operation history through the built job and packaged Worker', async () => { + expect(existsSync(job), 'built report job').toBe(true) + expect(existsSync(worker), 'packaged report Worker').toBe(true) + const directory = await mkdtemp(join(tmpdir(), 'desktop-operation-worker-')) + onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) }) + const record = { runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0, + operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z', + reason: 'bundle', phase: 'started', target: 'real-worker-test', stage: 'enable' } } + await writeFile(join(directory, 'operations.jsonl'), JSON.stringify(record) + '\n') + for (const mode of ['job', 'packaged']) { + const reportPath = join(directory, mode + '.log') + await writeFile(reportPath, 'basic report\n') + const input = { reportPath, directory, journal: null, + limits: { bytes: 65536, recordBytes: 4096, records: 100, outputBytes: 65536 } } + const script = join(directory, mode + '.mjs') + await writeFile(script, mode === 'job' + ? `import { runOperationReportJob } from ${JSON.stringify(job.href)};\nconsole.log(JSON.stringify(await runOperationReportJob(${JSON.stringify(input)}, { timeoutMs: 10000, cancellationGraceMs: 100 }, new AbortController().signal)));\n` + : `import { Worker } from 'node:worker_threads';\nconst worker = new Worker(${JSON.stringify(fileURLToPath(worker))}, { workerData: ${JSON.stringify(input)} });\nworker.on('error', error => { throw error });\nworker.on('exit', code => { if (code !== 0) process.exitCode = 1 });\n`) + const result = await execa(process.execPath, [script], { timeout: 30000, reject: false }) + expect(result.timedOut).toBe(false) + expect(result.signal).toBeUndefined() + expect(result.exitCode, result.stderr).toBe(0) + if (mode === 'job') expect(JSON.parse(result.stdout)).toEqual({ appended: true, timedOut: false, cancelled: false }) + const report = await readFile(reportPath, 'utf8') + expect(report.startsWith('basic report\n')).toBe(true) + const summary = JSON.parse(report.split('\n').find(line => line.startsWith('{'))!) as { records: unknown[] } + expect(summary.records).toEqual([record]) + expect(report).toContain('operation history collection complete') + } +}) + +it.skipIf(!existsSync(new URL('../lib/types/', import.meta.url)))('cancels the packaged Worker after an in-flight open settles', async () => { + const directory = await mkdtemp(join(tmpdir(), 'desktop-operation-cancel-')) + onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) }) + const reportPath = join(directory, 'report.log') + await writeFile(reportPath, 'basic report\n') + await writeFile(join(directory, 'operations.jsonl'), '{}\n') + const hook = join(directory, 'hook.mjs') + await writeFile(hook, `import fs from 'node:fs/promises'; +import { syncBuiltinESMExports } from 'node:module'; +import { parentPort } from 'node:worker_threads'; +const original = fs.open; +fs.open = async (...args) => { + const handle = await original(...args); + parentPort.postMessage('opened'); + await new Promise(resolve => parentPort.once('message', resolve)); + return handle; +}; +syncBuiltinESMExports(); +`) + const input = { reportPath, directory, journal: null, + limits: { bytes: 65536, recordBytes: 4096, records: 100, outputBytes: 65536 } } + const script = join(directory, 'cancel.mjs') + await writeFile(script, `import { Worker } from 'node:worker_threads'; +const worker = new Worker(${JSON.stringify(fileURLToPath(worker))}, { + workerData: ${JSON.stringify(input)}, execArgv: ['--import', ${JSON.stringify(pathToFileURL(hook).href)}] +}); +worker.on('message', message => { + if (message === 'opened') worker.postMessage('cancel'); + else console.log(JSON.stringify(message)); +}); +worker.on('error', error => { throw error }); +worker.on('exit', code => { process.exitCode = code }); +`) + const result = await execa(process.execPath, [script], { timeout: 30000, reject: false }) + expect(result.exitCode, result.stderr).toBe(0) + expect(result.stdout).toBe('false') + expect(await readFile(reportPath, 'utf8')).toBe('basic report\n') +}) diff --git a/apps/desktop/tests/operation-report.spec.ts b/apps/desktop/tests/operation-report.spec.ts new file mode 100644 index 0000000000..46e6f6a27f --- /dev/null +++ b/apps/desktop/tests/operation-report.spec.ts @@ -0,0 +1,73 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished } from 'vitest' +import { appendOperationReport } from '../src/operation-report.ts' +import { writeCrashReport, type CrashReportInput } from '../src/crash-report.ts' + +const input: CrashReportInput = { + operationHistoryPlanned: true, source: 'host', phase: 'startup', error: new Error('startup failed'), + rendererConsole: [], time: new Date('2026-10-19T00:00:00Z'), + app: { name: 'test', version: '1', platform: 'win32', arch: 'x64', electron: '44', node: '24', locale: 'zh-CN' }, +} +const limits = { bytes: 65536, recordBytes: 4096, records: 100, outputBytes: 65536 } +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'desktop-operation-report-')) + onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) }) + const report = await writeCrashReport(directory, input) + if (report === undefined) throw new Error('Basic report fixture was not saved') + return { directory, report, basic: await readFile(report, 'utf8') } +} + +it('preserves the independently saved basic report and appends whitelisted recent history', async () => { + const { directory, report, basic } = await fixture() + const record = { runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0, + operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z', + reason: 'bundle', phase: 'started', target: 'test-bundle', stage: 'enable', diagnostic: 'secret' } } + await writeFile(join(directory, 'operations.jsonl'), JSON.stringify(record) + '\n') + expect(await appendOperationReport(report, directory, limits, new AbortController().signal, null)).toBe(true) + const combined = await readFile(report, 'utf8') + expect(combined.startsWith(basic)).toBe(true) + expect(combined).toContain('test-bundle') + expect(combined).not.toContain('secret') + expect(combined).toContain('operation history collection complete') + await expect(combined.slice(basic.length)).toMatchFileSnapshot('./expected/operation-history.txt') +}) + +it('leaves basic bytes unchanged when collection is cancelled or fails before append', async () => { + const { directory, report, basic } = await fixture() + expect(await appendOperationReport(report, directory, limits, AbortSignal.abort(), null)).toBe(false) + expect(await appendOperationReport(report, directory, { ...limits, bytes: 0 }, new AbortController().signal, null)).toBe(false) + expect(await readFile(report, 'utf8')).toBe(basic) + expect(basic).toContain('unavailable or incomplete') +}) + +it('bounds the entire appended section and refuses a budget smaller than its metadata', async () => { + const { directory, report, basic } = await fixture() + expect(await appendOperationReport(report, directory, { ...limits, outputBytes: 1 }, new AbortController().signal, null)).toBe(false) + expect(await readFile(report, 'utf8')).toBe(basic) + expect(await appendOperationReport(report, directory, { ...limits, outputBytes: 512 }, new AbortController().signal, null)).toBe(true) + const summary = (await readFile(report, 'utf8')).slice(basic.length) + expect(Buffer.byteLength(summary)).toBeLessThanOrEqual(512) + expect(summary).toContain('"outputRecordsOmitted":0') +}) + +it('omits oldest records to fit the output budget, counting UTF-8 bytes and all metadata', async () => { + const { directory, report, basic } = await fixture() + const records = Array.from({ length: 20 }, (_, index) => ({ + runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0, + operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z', + reason: 'bundle', phase: 'started', target: `测试插件-${index}`, stage: 'enable' }, + })) + await writeFile(join(directory, 'operations.jsonl'), records.map(record => JSON.stringify(record)).join('\n') + '\n') + expect(await appendOperationReport(report, directory, { ...limits, outputBytes: 1024 }, new AbortController().signal, null)).toBe(true) + const section = (await readFile(report, 'utf8')).slice(basic.length) + expect(Buffer.byteLength(section)).toBeLessThanOrEqual(1024) + const summary = JSON.parse(section.split('\n').find(line => line.startsWith('{'))!) as { + outputRecordsOmitted: number + records: { operation: { target: string } }[] + } + expect(summary.outputRecordsOmitted).toBeGreaterThan(0) + expect(summary.records.length + summary.outputRecordsOmitted).toBe(20) + expect(summary.records[0]?.operation.target).toBe('测试插件-19') +}) diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index 2fe4b3aaf2..ba417ebce3 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -125,6 +125,42 @@ async function packageFixture(input: Awaited>) { }).pack([{ src: mapping.from, destination: join(destination, 'dsh'), files, metadata }]) } +it('includes the diagnostic worker in the archive selected by the shipping file rules', async () => { + const root = await mkdtemp(join(tmpdir(), 'diagnostic-worker-asar-')) + roots.push(root) + const appDir = join(root, 'app') + const resources = join(root, 'resources') + const destination = join(resources, 'app') + await mkdir(join(appDir, 'lib'), { recursive: true }) + await mkdir(resources) + const worker = join(appDir, 'lib', 'operation-report-worker.js') + const excluded = join(appDir, 'lib', 'not-shipped.js') + await writeFile(worker, 'export {}') + await writeFile(excluded, 'export {}') + const config = unsignedWindowsConfig('com.example.diagnostics', join(root, 'dsh')) + const filesConfig = config.files.map(entry => typeof entry === 'string' ? entry : { ...entry, filter: [...entry.filter] }) + const matchers = getFileMatchers({ files: filesConfig }, 'files', destination, { + defaultSrc: appDir, globalOutDir: root, customBuildOptions: {}, macroExpander: value => value, + })! + const matcher = matchers.find(entry => entry.from === appDir)! + const filter = matcher.createFilter() + const files: string[] = [] + const metadata = new Map() + for (const file of [worker, excluded]) { + const stat = await lstat(file) + if (filter(file, stat)) { + files.push(file) + metadata.set(file, stat) + } + } + await new AsarPackager({ info: { getWorkspaceRoot: async () => root } }, { + defaultDestination: destination, resourcePath: resources, options: {}, unpackPattern: () => false, + }).pack([{ src: appDir, destination, files, metadata }]) + const archive = await readAsar(join(resources, 'app.asar')) + expect(archive.getFile('lib/operation-report-worker.js').size).toBe(Buffer.byteLength('export {}')) + expect(() => archive.getFile('lib/not-shipped.js')).toThrow() +}) + it.each([false, true])('unpacks PE files through builder source patterns (external=%s)', async (external) => { const input = await fixture(external) const files = await prepareWindowsAsarUnpack(input.context, input.source) diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index b0bcc315f0..44992641d5 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -35,6 +35,9 @@ { "path": "../../packages/client/product-analytics/tsconfig.host.json" }, + { + "path": "../../packages/boot/plugin-manager" + }, { "path": "../../packages/util/file-drag" } diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index d4f034252d..40ed1a0316 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -33,6 +33,12 @@ if (clientVersion === undefined) throw new Error('desktop build: the client envi const clientVersionDefine = { 'process.env.DSH_CLIENT_VERSION': JSON.stringify(clientVersion) } export default defineConfig([ + { + entry: ['lib/types/operation-report-worker.js'], + plugins: [packagedImportsPlugin({ packages: new Set(), nodeBuiltins: true })], + outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, dts: false, clean: false, codeSplitting: false, + }, { entry: ['lib/types/command-manager-entry.js'], plugins: [packagedImportsPlugin({ packages: new Set(), nodeBuiltins: true })], diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 401508bc2c..87f9288d42 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -288,8 +288,11 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass }) mock.unary('session/attachment', (request: unknown) => { request = recordValue(request, 'request') - const attachmentId = recordString(request, 'attachmentId') - return attachments.get(attachmentId) ?? { + const attachment = recordValue(request, 'attachment') + const attachmentId = recordString(attachment, 'attachmentId') + const stored = attachments.get(attachmentId) + if (stored !== undefined) return ok({ attachment, data: recordString(recordValue(stored, 'value'), 'data') }) + return { ok: false, error: { code: 'session/attachment-invalid', diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts index f8afb08a67..37c2199298 100644 --- a/apps/web/tests/cot-translation-paid.e2e.ts +++ b/apps/web/tests/cot-translation-paid.e2e.ts @@ -196,7 +196,7 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor() await panel.getByText(bundleVersion, { exact: true }).waitFor() // The bundle's detail document is shown between its components and its source. - await panel.locator('[data-detail-document]').getByRole('heading', { name: '使用方法', exact: true }).waitFor() + await panel.locator('[data-detail-document]').getByRole('img', { name: '展开的思考行从英文翻译成中文,并显示“查看原文”按钮', exact: true }).waitFor() await compareOrRefreshGolden(join(EXPECTED, 'paid-choices.expected.md'), await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']], diff --git a/apps/web/tests/cot-translation.e2e.ts b/apps/web/tests/cot-translation.e2e.ts index cf10562c90..c4db45b575 100644 --- a/apps/web/tests/cot-translation.e2e.ts +++ b/apps/web/tests/cot-translation.e2e.ts @@ -213,7 +213,7 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor() await panel.getByText(bundleVersion, { exact: true }).waitFor() // The bundle's detail document is shown between its components and its source. - await panel.locator('[data-detail-document]').getByRole('heading', { name: '使用方法', exact: true }).waitFor() + await panel.locator('[data-detail-document]').getByRole('img', { name: '展开的思考行从英文翻译成中文,并显示“查看原文”按钮', exact: true }).waitFor() await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'), await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']], diff --git a/apps/web/tests/cua-optin.e2e.ts b/apps/web/tests/cua-optin.e2e.ts index d775874a78..a9ddcde869 100644 --- a/apps/web/tests/cua-optin.e2e.ts +++ b/apps/web/tests/cua-optin.e2e.ts @@ -1,5 +1,5 @@ /** CUA follows PTC Agent scopes and changes the default only from its detail page. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -15,10 +15,11 @@ import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts' // The shipped bundle admits only macOS Hosts; unsupported Hosts are exercised in bundle-platform.e2e.ts. it.skipIf(process.platform !== 'darwin').each([ - { selectedDefault: undefined, codingTools: false }, - { selectedDefault: undefined, codingTools: true }, - { selectedDefault: 'minimal', codingTools: true }, -] as const)('preserves Coding Tools=$codingTools and default=$selectedDefault across CUA toggles', async ({ selectedDefault, codingTools }) => { + { selectedDefault: undefined, codingTools: false, script: false }, + { selectedDefault: undefined, codingTools: true, script: false }, + { selectedDefault: 'minimal', codingTools: true, script: false }, + { selectedDefault: undefined, codingTools: true, script: true }, +] as const)('preserves Coding Tools=$codingTools and default=$selectedDefault and Script=$script across CUA toggles', async ({ selectedDefault, codingTools, script }) => { const directory = await mkdtemp(join(tmpdir(), 'cua-optin-web-')) let ownedScaffold: WebScaffold | undefined let browser: Browser | undefined @@ -26,10 +27,18 @@ it.skipIf(process.platform !== 'darwin').each([ const patch = join(directory, 'native-fixture.yml') // Permission inspection fails against this non-helper executable; activation still permits dismissing setup. await writeFile(patch, JSON.stringify([{ id: 'cua', config: { helperPath: process.execPath } }])) + const defaults = join(directory, 'script-defaults') + if (script) { + await mkdir(defaults) + await writeFile(join(defaults, 'package.json'), JSON.stringify({ name: 'cua-script-defaults', version: '1.0.0', dsh: { bundle: { patch: 'cordis.patch.yml' } } })) + await writeFile(join(defaults, 'cordis.patch.yml'), '- id: ptc-runtime\n config:\n executionMode: vm-script\n scriptPersistence: false\n') + } const harnessHome = join(directory, 'home') - const scaffold = ownedScaffold = await launchWebScaffold({ harnessHome, extraOverlayPath: [ - fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), patch, - ] }) + const scaffold = ownedScaffold = await launchWebScaffold({ + profile: { packages: script ? [{ dir: defaults, enabled: true }] : [] }, + harnessHome, extraOverlayPath: [ + fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), patch, + ] }) const initialDefault = selectedDefault ?? 'standard' if (selectedDefault !== undefined) await scaffold.ctx.settings.update('agent-preset-registry', { selectedDefault }) await scaffold.ctx.settings.update('ui-settings', { enabled: codingTools }) @@ -67,9 +76,12 @@ it.skipIf(process.platform !== 'darwin').each([ setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') }, })).agent expect(scaffold.ctx.tools.schemas(ptcAgent).some(tool => tool.name === 'cua')).toBe(false) + const runtimeFiber = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')?.fiber + const originalRuntimeFiber = runtimeFiber() + expect(originalRuntimeFiber).toBeDefined() const originalRuntime = scaffold.ctx.ptcRuntime expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) - expect(originalRuntime.programFormat).toBe('function-body') + expect(originalRuntime.programFormat).toBe(script ? 'script' : 'function-body') expect(scaffold.ctx.get('computerUse')).toBeUndefined() const profileManifest = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold')) const selected = () => profileManifest().dsh?.profile?.bundles @@ -140,7 +152,8 @@ it.skipIf(process.platform !== 'darwin').each([ await child.dispose() expect(scaffold.ctx.tools.schemas(child.agent).some(tool => tool.name === 'cua')).toBe(false) expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) - expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') + expect(runtimeFiber() === originalRuntimeFiber).toBe(true) + expect(scaffold.ctx.ptcRuntime.programFormat).toBe(script ? 'script' : 'function-body') await setup.waitFor() expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1) await setup.getByRole('button', { name: '稍后', exact: true }).click() @@ -174,12 +187,14 @@ it.skipIf(process.platform !== 'darwin').each([ const beforePersistent = scaffold.ctx.ptcRuntime await persistent.getByRole('button', { name: '设为持久化', exact: true }).click() await persistent.getByRole('button', { name: '已设为持久化', exact: true }).waitFor() - expect(scaffold.ctx.ptcRuntime.programFormat).toBe('repl') + expect(scaffold.ctx.ptcRuntime.programFormat).toBe(script ? 'script' : 'repl') + expect(scaffold.ctx.ptcRuntime.retainsState).toBe(true) expect(scaffold.ctx.ptcRuntime === beforePersistent).toBe(false) saved = await readFile(settingsPath, 'utf8') - expect(saved).toContain('executionMode: persistent-in-memory') + expect(saved).toContain(`executionMode: ${script ? 'vm-script' : 'persistent-in-memory'}`) + expect(saved).toContain(`scriptPersistence: ${script}`) const runtime = scaffold.ctx.ptcRuntime - const runtimeFiber = [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')!.fiber + const persistentRuntimeFiber = runtimeFiber() const owner = {} const program = 'globalThis.counter = (globalThis.counter ?? 0) + 1' expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(1) @@ -187,7 +202,7 @@ it.skipIf(process.platform !== 'darwin').each([ await toggle.waitFor({ state: 'hidden' }) await card.waitFor({ state: 'hidden' }) expect(scaffold.ctx.get('computerUse')).toBeUndefined() - expect([...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')!.fiber === runtimeFiber).toBe(true) + expect(runtimeFiber() === persistentRuntimeFiber).toBe(true) expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) expect(scaffold.ctx.agents.get(ptcAgent.id) === ptcAgent).toBe(true) for (const agent of [ptcAgent, fresh]) { @@ -226,7 +241,7 @@ it.skipIf(process.platform !== 'darwin').each([ })).agent expect(scaffold.ctx.agentPresets.composedPreset(confirmed.ctx)).toBe('ptc') expect(scaffold.ctx.tools.schemas(confirmed).some(tool => tool.name === 'cua')).toBe(true) - expect([...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')!.fiber === runtimeFiber).toBe(true) + expect(runtimeFiber() === persistentRuntimeFiber).toBe(true) expect(scaffold.ctx.tools.schemas(ptcAgent).some(tool => tool.name === 'cua')).toBe(true) expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(3) const confirmedSettings = await readFile(settingsPath, 'utf8') diff --git a/apps/web/tests/expected/additional-locales/de.expected.md b/apps/web/tests/expected/additional-locales/de.expected.md index 210ffed2fb..1f6b2b5a77 100644 --- a/apps/web/tests/expected/additional-locales/de.expected.md +++ b/apps/web/tests/expected/additional-locales/de.expected.md @@ -1,5 +1,5 @@ - listitem: - button "Siehe Automatische Autorisierungsüberprüfung": Automatische Autorisierungsüberprüfung - - text: Experimentell Fügen Sie einen Auto review-Berechtigungsmodus hinzu, der das Modell verwendet, um die Autorisierung vor jedem Toolaufruf zu bewerten. + - text: "Experimentell Fügt den Zugriffsmodus „Auto review“ hinzu: Vor jedem Tool-Aufruf prüft ein Modell den Aufruf anhand Ihrer Anforderung; zugelassene Aufrufe laufen sofort, abgelehnte Aufrufe warten auf Ihre Genehmigung." - status - switch "Aktivieren Sie Automatische Autorisierungsüberprüfung" diff --git a/apps/web/tests/expected/additional-locales/es.expected.md b/apps/web/tests/expected/additional-locales/es.expected.md index c1e0233d70..a2a434ac84 100644 --- a/apps/web/tests/expected/additional-locales/es.expected.md +++ b/apps/web/tests/expected/additional-locales/es.expected.md @@ -1,5 +1,5 @@ - listitem: - button "Ver Revisión de autorización automática": Revisión de autorización automática - - text: Experimental Agregue un modo de permiso Auto review que utilice el modelo para evaluar la autorización antes de cada llamada a la herramienta. + - text: "Experimental Añade el modo de acceso “Auto review”: antes de cada llamada a una herramienta, un modelo comprueba la llamada frente a lo que usted pidió; las llamadas permitidas se ejecutan de inmediato y las denegadas esperan su aprobación." - status - switch "Habilitar Revisión de autorización automática" diff --git a/apps/web/tests/expected/additional-locales/fr.expected.md b/apps/web/tests/expected/additional-locales/fr.expected.md index 88c6dad142..c589a1ec21 100644 --- a/apps/web/tests/expected/additional-locales/fr.expected.md +++ b/apps/web/tests/expected/additional-locales/fr.expected.md @@ -1,5 +1,5 @@ - listitem: - button "Voir Examen des autorisations automatiques": Examen des autorisations automatiques - - text: Expérimental Ajoutez un mode d'autorisation Auto review qui utilise le modèle pour évaluer l'autorisation avant chaque appel d'outil. + - text: "Expérimental Ajoute le mode d'accès « Auto review » : avant chaque appel d'outil, un modèle vérifie l'appel par rapport à ce que vous avez demandé ; les appels autorisés s'exécutent immédiatement et les appels refusés attendent votre approbation." - status - switch "Activer Examen des autorisations automatiques" diff --git a/apps/web/tests/expected/additional-locales/pt.expected.md b/apps/web/tests/expected/additional-locales/pt.expected.md index 1119ad2aa1..a9393bf830 100644 --- a/apps/web/tests/expected/additional-locales/pt.expected.md +++ b/apps/web/tests/expected/additional-locales/pt.expected.md @@ -1,5 +1,5 @@ - listitem: - button "Ver Revisão de autorização automática": Revisão de autorização automática - - text: Experimental Adicione um modo de permissão Auto review que usa o modelo para avaliar a autorização antes de cada chamada de ferramenta. + - text: "Experimental Adiciona o modo de acesso “Auto review”: antes de cada chamada de ferramenta, um modelo verifica a chamada em relação ao que você pediu; as chamadas permitidas são executadas imediatamente e as negadas aguardam sua aprovação." - status - switch "Ativar Revisão de autorização automática" diff --git a/apps/web/tests/expected/additional-locales/ru.expected.md b/apps/web/tests/expected/additional-locales/ru.expected.md index e4f02c9c69..5d48baee88 100644 --- a/apps/web/tests/expected/additional-locales/ru.expected.md +++ b/apps/web/tests/expected/additional-locales/ru.expected.md @@ -1,5 +1,5 @@ - listitem: - button "Посмотреть Проверка автоматической авторизации": Проверка автоматической авторизации - - text: Экспериментальный Добавьте режим разрешений Auto review, который использует модель для оценки авторизации перед каждым вызовом инструмента. + - text: "Экспериментальный Добавляет режим доступа «Auto review»: перед каждым вызовом инструмента модель сверяет вызов с вашей просьбой; разрешённые вызовы выполняются сразу, а отклонённые ждут вашего одобрения." - status - switch "Включить Проверка автоматической авторизации" diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md index a141beff7b..dedc4bb631 100644 --- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md +++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md @@ -6,57 +6,10 @@ - text: v{{version}} 实验性 - paragraph: - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" -- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 +- paragraph: 智能体输出时,把展开的“思考”行翻译成你选择的语言,点击“查看原文”可切换回原文。 - region "详细介绍": - paragraph: - img "展开的思考行从英文翻译成中文,并显示“查看原文”按钮" - - paragraph: - - text: 展开 - - strong: 思考 - - text: 行,即可用界面语言阅读其中的思考内容。智能体输出时会逐段翻译;最新的内容在能够翻译之前保持原文。 - - heading "使用方法" [level=2] - - list: - - listitem: - - text: 展开 - - strong: 思考 - - text: 行。点击 - - strong: 查看原文 - - text: 显示原文,点击 - - strong: 查看译文 - - text: 切换回译文。 - - listitem: - - text: 在此插件的页面中选择 - - strong: 翻译服务 - - text: 和 - - strong: 目标语言 - - text: ,然后点击 - - strong: 保存 - - text: 。 - - code: auto - - text: 表示跟随界面语言;也可以填写 - - code: zh - - text: 、 - - code: en - - text: 、 - - code: ja - - text: 等语言代码。 - - listitem: - - text: 翻译失败时,原文保持显示,并提供 - - strong: 重试 - - text: 。 - - heading "注意事项" [level=2] - - list: - - listitem: 你展开的思考内容可能包含你的代码和对话细节。 - - listitem: 默认使用 Bing,也可以选择 Google。两者都无需账号或 API key,但都不保证可用,也不保证免费额度。 - - listitem: - - text: 如果已配置 DeepSeek 账号或官方 API,还会出现 - - strong: DeepSeek Flash · 账号(付费) - - text: 和 - - strong: DeepSeek Flash · 官方 API(付费) - - text: 。此时每段没有已保存译文的内容都是一次付费请求。 - - listitem: 译文保存在会话中,重新打开时直接复用。原始思考内容和模型的输入都不会改变。 - - listitem: 折叠时显示的摘要保持原文。翻译可能改变 Markdown 格式或代码。 - - listitem: 关闭此插件会移除它的按钮并取消未完成的翻译;已保存的译文仍保留在会话中。 - paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。 - paragraph: 每个未缓存片段都会通过所选账号或官方 API 凭据单独发送一次付费的 DeepSeek Flash 请求;重新展开会复用已保存的译文,更改服务、语言、原文或请求设置可能发送新的付费请求 - text: 翻译服务 已覆盖 diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md index 6404a2d8bc..c3da36d678 100644 --- a/apps/web/tests/expected/cot-translation/preferences.expected.md +++ b/apps/web/tests/expected/cot-translation/preferences.expected.md @@ -6,57 +6,10 @@ - text: v{{version}} 实验性 - paragraph: - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" -- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 +- paragraph: 智能体输出时,把展开的“思考”行翻译成你选择的语言,点击“查看原文”可切换回原文。 - region "详细介绍": - paragraph: - img "展开的思考行从英文翻译成中文,并显示“查看原文”按钮" - - paragraph: - - text: 展开 - - strong: 思考 - - text: 行,即可用界面语言阅读其中的思考内容。智能体输出时会逐段翻译;最新的内容在能够翻译之前保持原文。 - - heading "使用方法" [level=2] - - list: - - listitem: - - text: 展开 - - strong: 思考 - - text: 行。点击 - - strong: 查看原文 - - text: 显示原文,点击 - - strong: 查看译文 - - text: 切换回译文。 - - listitem: - - text: 在此插件的页面中选择 - - strong: 翻译服务 - - text: 和 - - strong: 目标语言 - - text: ,然后点击 - - strong: 保存 - - text: 。 - - code: auto - - text: 表示跟随界面语言;也可以填写 - - code: zh - - text: 、 - - code: en - - text: 、 - - code: ja - - text: 等语言代码。 - - listitem: - - text: 翻译失败时,原文保持显示,并提供 - - strong: 重试 - - text: 。 - - heading "注意事项" [level=2] - - list: - - listitem: 你展开的思考内容可能包含你的代码和对话细节。 - - listitem: 默认使用 Bing,也可以选择 Google。两者都无需账号或 API key,但都不保证可用,也不保证免费额度。 - - listitem: - - text: 如果已配置 DeepSeek 账号或官方 API,还会出现 - - strong: DeepSeek Flash · 账号(付费) - - text: 和 - - strong: DeepSeek Flash · 官方 API(付费) - - text: 。此时每段没有已保存译文的内容都是一次付费请求。 - - listitem: 译文保存在会话中,重新打开时直接复用。原始思考内容和模型的输入都不会改变。 - - listitem: 折叠时显示的摘要保持原文。翻译可能改变 Markdown 格式或代码。 - - listitem: 关闭此插件会移除它的按钮并取消未完成的翻译;已保存的译文仍保留在会话中。 - paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。 - paragraph: Bing 和 Google 翻译无需额外登录或 API key,但匿名接口可能拒绝或限制请求 - text: 翻译服务 diff --git a/apps/web/tests/expected/japanese-plugin-metadata/card.expected.md b/apps/web/tests/expected/japanese-plugin-metadata/card.expected.md index 0fa58e0c23..df7ab7b97b 100644 --- a/apps/web/tests/expected/japanese-plugin-metadata/card.expected.md +++ b/apps/web/tests/expected/japanese-plugin-metadata/card.expected.md @@ -1,5 +1,5 @@ - listitem: - button "自動認可レビューを表示": 自動認可レビュー - - text: 実験的 各ツール呼び出しの前にモデルが認可を判断する、自動認可レビュー権限モードを提供します。 + - text: 実験的 「自動認可レビュー」アクセスモードを追加します。各ツール呼び出しの前に、モデルがその呼び出しをあなたの依頼内容と照らし合わせて確認し、許可された呼び出しは即座に実行され、拒否された呼び出しはあなたの承認を待ちます。 - status - switch "自動認可レビューを有効化" diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 826732727e..0140faf9af 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -24,7 +24,7 @@ - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 + - text: 实验性 让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。 - status - switch "启用 智能体团队" - listitem: @@ -34,12 +34,12 @@ - switch "启用 语音输入" - listitem: - button "查看 自定义插件索引": 自定义插件索引 - - text: 实验性 在插件页添加「发现」标签页,从你添加的插件索引浏览和安装插件。 + - text: 实验性 在插件页添加“发现”标签页,从你添加的插件索引中浏览和安装插件。 - status - switch "启用 自定义插件索引" - listitem: - button "查看 自动授权审查": 自动授权审查 - - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。 - status - switch "启用 自动授权审查" - heading "已安装插件" [level=3] diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index dfdde9ceb5..d5cb4aac9a 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -24,7 +24,7 @@ - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 + - text: 实验性 让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。 - status - switch "启用 智能体团队" - listitem: @@ -34,12 +34,12 @@ - switch "启用 语音输入" - listitem: - button "查看 自定义插件索引": 自定义插件索引 - - text: 实验性 在插件页添加「发现」标签页,从你添加的插件索引浏览和安装插件。 + - text: 实验性 在插件页添加“发现”标签页,从你添加的插件索引中浏览和安装插件。 - status - switch "启用 自定义插件索引" - listitem: - button "查看 自动授权审查": 自动授权审查 - - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。 - status - switch "启用 自动授权审查" - heading "已安装插件" [level=3] diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index cd2e2ac168..3d0660724f 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -24,7 +24,7 @@ - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 + - text: 实验性 让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。 - status - switch "启用 智能体团队" - listitem: @@ -34,12 +34,12 @@ - switch "启用 语音输入" - listitem: - button "查看 自定义插件索引": 自定义插件索引 - - text: 实验性 在插件页添加「发现」标签页,从你添加的插件索引浏览和安装插件。 + - text: 实验性 在插件页添加“发现”标签页,从你添加的插件索引中浏览和安装插件。 - status - switch "启用 自定义插件索引" - listitem: - button "查看 自动授权审查": 自动授权审查 - - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。 - status - switch "启用 自动授权审查" - heading "已安装插件" [level=3] diff --git a/apps/web/tests/expected/shared-inspector/metadata-en.expected.md b/apps/web/tests/expected/shared-inspector/metadata-en.expected.md index 8a700d7178..7a2186869b 100644 --- a/apps/web/tests/expected/shared-inspector/metadata-en.expected.md +++ b/apps/web/tests/expected/shared-inspector/metadata-en.expected.md @@ -1,5 +1,5 @@ - bundle: Developer tools - description: Inspect and debug raw session data, grouped chat messages, and the NodeJS backend. + description: For troubleshooting: view what DSH recorded for a session in Session log, and debug the DSH backend in Chrome DevTools. - component: NodeJS inspector description: Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging. - component: Session log diff --git a/apps/web/tests/expected/shared-inspector/metadata-zh.expected.md b/apps/web/tests/expected/shared-inspector/metadata-zh.expected.md index ac290833d9..eb3a0e2849 100644 --- a/apps/web/tests/expected/shared-inspector/metadata-zh.expected.md +++ b/apps/web/tests/expected/shared-inspector/metadata-zh.expected.md @@ -1,5 +1,5 @@ - bundle: 开发者工具 - description: 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 + description: 用于排查问题:在“会话数据诊断”中查看 DSH 为会话记录的内容,并在 Chrome DevTools 中调试 DSH 后端。 - component: NodeJS 诊断 description: 按 Ctrl/Cmd+Shift+. 在底部面板打开 Chrome DevTools 进行开发调试 - component: 会话数据诊断 diff --git a/apps/web/tests/japanese-plugin-metadata.e2e.ts b/apps/web/tests/japanese-plugin-metadata.e2e.ts index fb56add4f1..9d9103cff9 100644 --- a/apps/web/tests/japanese-plugin-metadata.e2e.ts +++ b/apps/web/tests/japanese-plugin-metadata.e2e.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url' import { chromium, type Browser } from 'playwright' import { expect, it, onTestFailed, onTestFinished } from 'vitest' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { experimentalBundleDescription, saveFailureShot } from './support.ts' it('displays Japanese plugin titles and descriptions from package metadata', async () => { const resources: { scaffold?: WebScaffold; browser?: Browser } = {} @@ -23,7 +23,7 @@ it('displays Japanese plugin titles and descriptions from package metadata', asy const title = panel.getByRole('button', { name: '自動認可レビューを表示', exact: true }) await title.waitFor() const card = title.locator('xpath=ancestor::li') - await card.getByText('各ツール呼び出しの前にモデルが認可を判断する、自動認可レビュー権限モードを提供します。', { exact: true }).waitFor() + await card.getByText(experimentalBundleDescription('auto-review', 'ja'), { exact: true }).waitFor() const expected = fileURLToPath(new URL('./expected/japanese-plugin-metadata/card.expected.md', import.meta.url)) await compareOrRefreshGolden(expected, await captureStableAria(page, '[data-plugin-panel] li:has(button[aria-label="自動認可レビューを表示"])', scaffold.workspaceCwd), webSnapshotMode()) diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index bec53293a7..ee65497459 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -1,11 +1,12 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import type { SessionAttachmentValue } from '@deepseek-ai/dsh-api-session-controller' import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -18,11 +19,15 @@ import { compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, + parseSeedFixture, + rawSessionLog, + renderSeedFixture, + seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/minimal-preset', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') @@ -322,6 +327,38 @@ describe('minimal image reading', () => { let browser: Browser | undefined let page: Page | undefined + async function openSession(id: SessionId): Promise { + if (browser === undefined) throw new Error('image scenario browser has not started') + await page?.close() + page = await newEnglishPage(browser) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator(`[data-row-key="session:${id}"]`).click() + return page + } + + async function imageCard(current: Page): Promise { + const card = current.locator('[data-tool="read_image"]').first() + await card.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(current, card) + const row = card.locator('[data-disclosure-row]') + if (await row.getAttribute('aria-expanded') === 'false') await row.click() + return card + } + + function interruptedTurn(turn: number, text: string): string { + return [ + { type: 'turn/start', data: { turn } }, + { type: 'step/start', data: { turn, step: 1 } }, + { + type: 'user/message', surfaceOp: 'append', + data: createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }), + }, + { type: 'step/end', data: { turn, step: 1 } }, + { type: 'turn/end', data: { turn, reason: { kind: 'interrupted' } } }, + ].map(event => JSON.stringify(event)).join('\n') + } + beforeAll(async () => { scaffold = await launchWebScaffold({ replayFixture: join(snapshotDir, 'session.v4.jsonl'), compareReplaySession: true, paceMs: 10, @@ -337,6 +374,7 @@ describe('minimal image reading', () => { }) handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } })) await handle.agent.whenIdle() + if (MODE !== 'record') browser = await chromium.launch() }) afterAll(async () => { @@ -363,21 +401,10 @@ describe('minimal image reading', () => { it.skipIf(MODE === 'record')('renders the existing image tool card and preview', async () => { onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-minimal-read-image') }) - browser = await chromium.launch() - page = await newEnglishPage(browser) + page = await openSession(handle.agent.session.id) const tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await page.locator('[role="treeitem"]').first().click() - await page.locator('[role="treeitem"]').nth(1).click() await page.getByText('MINIMAL_IMAGE_OK', { exact: true }).waitFor() - await page.locator('[data-turn-process]').click() - const group = page.locator('[data-chat-group-key]').first() - await group.locator('[data-process-activity]').click() - const card = group.locator('[data-tool="read_image"]').first() - const row = card.locator('[data-disclosure-row]') - await row.click() - await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') + const card = await imageCard(page) const preview = card.locator('img').first() await preview.waitFor() await expect.poll(() => preview.evaluate(img => img instanceof HTMLImageElement && img.naturalWidth)).toBe(1) @@ -387,9 +414,143 @@ describe('minimal image reading', () => { expect(tripwire.warnings).toEqual([]) }) + it.skipIf(MODE === 'record')('reads image bytes and preserves the supplied reference with another Session as the gateway scope', async () => { + const image = handle.agent.session.deriveMessages().flatMap(message => message.content).find(block => block.type === 'image') + if (image?.type !== 'image') throw new Error('minimal read_image produced no durable image') + const unrelated = await scaffold.ctx.agents.create({ sessionId: SessionId('minimal-image-unrelated') }) + try { + expect(unrelated.agent.session.deriveMessages().flatMap(message => message.content).some(block => block.type === 'image')).toBe(false) + const read = async (attachment: unknown, rpcId: string) => { + const response = await scaffold.hostFetch('/api/session/attachment', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId, method: 'session/attachment', + payload: { args: { request: { sessionId: unrelated.agent.session.id, attachment } } }, + }), + }) + expect(response.ok).toBe(true) + return await response.json() as { result: { ok: boolean; value?: SessionAttachmentValue; error?: { code: string } } } + } + const reference = { ...image.attachment, name: 'caller-preview.png' } + const body = await read(reference, 'minimal-image-unrelated') + const stored = await scaffold.ctx.attachments.readImage(image.attachment) + expect(body.result).toEqual({ + ok: true, + value: { attachment: reference, data: Buffer.from(stored.data).toString('base64') }, + }) + const mismatches = [ + { mediaType: 'image/jpeg' }, { bytes: reference.bytes + 1 }, + { width: reference.width + 1 }, { height: reference.height + 1 }, + ] + for (const [index, mismatch] of mismatches.entries()) { + const rejected = await read({ ...reference, ...mismatch }, `minimal-image-mismatch-${index}`) + expect(rejected.result).toMatchObject({ + ok: false, error: { code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_CORRUPT' } }, + }) + expect(rejected.result.value).toBeUndefined() + } + const invalidFields = [ + { mediaType: 'image/svg+xml' }, { bytes: String(reference.bytes) }, + { width: String(reference.width) }, { height: String(reference.height) }, + ] + for (const [index, invalid] of invalidFields.entries()) { + const rejected = await read({ ...reference, ...invalid }, `minimal-image-schema-${index}`) + expect(rejected.result).toMatchObject({ ok: false, error: { code: 'gateway/input-invalid' } }) + expect(rejected.result.value).toBeUndefined() + } + } finally { + await unrelated.dispose() + } + }) + + it.skipIf(MODE === 'record')('loads persisted images after opening, paging older history, and forking', async () => { + onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-historical-read-image') }) + const { headerLine, events } = parseSeedFixture(rawSessionLog(handle.agent.session)) + const recorded = renderSeedFixture(headerLine, events) + // User-only interrupted turns move the recorded image beyond the 50-message initial page without inventing model output. + const laterTurns = Array.from({ length: 52 }, (_, index) => + interruptedTurn(index + 2, `Later image-history message ${index + 2}`)) + const historicalId = SessionId('minimal-image-cold') + await seedSession(scaffold, `${recorded}${laterTurns[0]}\n`, historicalId, 'minimal') + expect(scaffold.ctx.agents.get(historicalId)).toBeUndefined() + page = await openSession(historicalId) + let card = await imageCard(page) + await expect.poll(() => card.locator('img').first().evaluate(img => img instanceof HTMLImageElement && img.naturalWidth)).toBe(1) + + const pagedId = await seedSession(scaffold, `${recorded}${laterTurns.join('\n')}\n`, 'minimal-image-paged', 'minimal') + expect(scaffold.ctx.agents.get(pagedId)).toBeUndefined() + page = await openSession(pagedId) + await page.getByText('Later image-history message 53', { exact: true }).waitFor() + expect(await page.locator('[data-tool="read_image"]').count()).toBe(0) + await page.getByRole('button', { name: 'Load earlier', exact: true }).click() + card = await imageCard(page) + await expect.poll(() => card.locator('img').first().evaluate(img => img instanceof HTMLImageElement && img.naturalWidth)).toBe(1) + + const response = await scaffold.hostFetch('/api/session/fork', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'minimal-image-fork', method: 'session/fork', + payload: { args: { request: { sessionId: historicalId } } }, + }), + }) + expect(response.ok).toBe(true) + const body = await response.json() as { result: { ok: boolean; value?: { sessionId: string } } } + expect(body.result.ok).toBe(true) + if (body.result.value === undefined) throw new Error('image fork did not return a Session ID') + page = await openSession(SessionId(body.result.value.sessionId)) + card = await imageCard(page) + await expect.poll(() => card.locator('img').first().evaluate(img => img instanceof HTMLImageElement && img.naturalWidth)).toBe(1) + }) + + it.skipIf(MODE === 'record')('ends loading with a retryable failure when the persisted image is missing', async () => { + onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-missing-read-image') }) + const image = handle.agent.session.deriveMessages().flatMap(message => message.content).find(block => block.type === 'image') + if (image?.type !== 'image') throw new Error('minimal read_image produced no durable image') + // The blue pixel owns a different storage object from the recorded red pixel. + const missingImage = await scaffold.ctx.attachments.saveImage({ + data: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYPgPAAEDAQAIicLsAAAAAElFTkSuQmCC', 'base64'), + mediaType: 'image/png', name: 'red.png', + }) + expect(missingImage.attachmentId).not.toBe(image.attachment.attachmentId) + const { headerLine, events } = parseSeedFixture(rawSessionLog(handle.agent.session)) + const missingEvents = events.map(event => event.type === 'tool/result' + ? { + ...event, + data: { + ...event.data, + message: { + ...event.data.message, + content: event.data.message.content.map(block => block.type === 'image' + ? { ...block, attachment: missingImage } + : block), + }, + }, + } + : event) + const missingId = await seedSession(scaffold, + `${renderSeedFixture(headerLine, missingEvents)}${interruptedTurn(2, 'Check the missing image')}\n`, + 'minimal-image-missing', 'minimal') + const stored = await scaffold.ctx.attachments.readImage(missingImage) + const path = scaffold.ctx.attachments.imageHostPath(missingImage) + if (path === undefined) throw new Error('local attachment store did not return the stored image path') + await unlink(path) + try { + page = await openSession(missingId) + const card = await imageCard(page) + await card.getByRole('button', { name: 'Image failed to load; click to retry', exact: true }).waitFor() + expect(await card.getByText('Loading image…', { exact: true }).count()).toBe(0) + expect(await card.locator('[aria-busy="true"]').count()).toBe(0) + expect(await card.locator('img').count()).toBe(0) + await compareOrRefreshGolden(join(snapshotDir, 'missing-image.expected.md'), + await captureStableAria(page, '[data-tool="read_image"]', scaffold.workspaceCwd), MODE) + } finally { + await scaffold.ctx.attachments.saveImage({ data: stored.data, mediaType: stored.ref.mediaType }) + } + }) + it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(snapshotDir, [ - 'session.v4.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'ui.expected.md', + 'session.v4.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'ui.expected.md', 'missing-image.expected.md', ]) }) }) diff --git a/apps/web/tests/plan-control-row.e2e.ts b/apps/web/tests/plan-control-row.e2e.ts index 99146362a1..c32960a14e 100644 --- a/apps/web/tests/plan-control-row.e2e.ts +++ b/apps/web/tests/plan-control-row.e2e.ts @@ -256,6 +256,27 @@ describe('web e2e: plan chip click area at the narrow viewport', () => { expect(tripwire.warnings).toEqual([]) }, 200_000) + // The suite above only reaches the icon through the Plan chip's width. This + // case keeps the row otherwise empty: a long model name must still exceed + // the row above the mobile minimum, so a row-relative width cap cannot hide + // the demand behind a shrunken, ellipsized name. + it('collapses the model text on a narrow composer without another control', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-model-collapse-no-plan')) + await page.locator('[data-composer-input]').first().waitFor({ timeout: 10_000 }) + await resizeControls(page, 1680) + await selectModel(page, LONG_MODEL) + await expectControlLayout(page, false) + + await resizeControls(page, 440) + await expectControlLayout(page, true) + const trigger = page.getByRole('button', { name: /Select model/ }) + expect(await trigger.getAttribute('title')).toContain(LONG_MODEL) + expect(await trigger.getAttribute('aria-label')).toContain(LONG_MODEL) + + await resizeControls(page, 1680) + await expectControlLayout(page, false) + }, 120_000) + it('keeps the snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl']) }) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 14d32cb9ac..efb336cb09 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -17,7 +17,7 @@ import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { - ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, openSettings, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot, + ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, experimentalBundleDescription, openSettings, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot, } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url)) @@ -842,7 +842,7 @@ describe('web e2e: plugin manager', () => { expect(await panel.getByText(/Cordis|Chrome DevTools/).count()).toBe(0) await panel.getByRole('button', { name: '更多', exact: true }).click() await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() - expect(await panel.getByText('查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端', { exact: true }).count()).toBe(1) + expect(await panel.getByText(experimentalBundleDescription('inspector-profile', 'zh'), { exact: true }).count()).toBe(1) const bundles = await scaffold.ctx.pluginManager.listBundles() expect(bundles.some(bundle => bundle.name === '@deepseek-ai/dsh-experimental-terminal-bundle')).toBe(true) expect(await panel.getByRole('switch', { name: '启用 持久终端', exact: true }).count()).toBe(0) @@ -1096,7 +1096,7 @@ describe('web e2e: plugin manager', () => { await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() const packageName = panel.locator('[data-plugin-name]') expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') - expect(await panel.getByText('启用团队协作、团队工具、成员列表和共享任务看板。').count()).toBe(1) + expect(await panel.getByText(experimentalBundleDescription('agent-team-profile', 'zh')).count()).toBe(1) const child = panel.locator('[data-plugin-row]', { hasText: 'tool-agent-team' }) await child.getByText('团队工具', { exact: true }).waitFor() expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1) @@ -1104,7 +1104,7 @@ describe('web e2e: plugin manager', () => { await setLanguage('en') await panel.getByRole('heading', { name: 'Agent teams', exact: true }).waitFor() expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') - expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1) + expect(await panel.getByText(experimentalBundleDescription('agent-team-profile', 'en')).count()).toBe(1) await child.getByText('Team tools', { exact: true }).waitFor() expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1) await panel.getByRole('button', { name: 'Back to plugins' }).click() diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 3ca55f2f85..302aea3fdc 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -85,11 +85,11 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) const signal = new AbortController().signal const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'one-shot' } as const - const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal) + const childPage = await scaffold.ctx.sessionController.events({ address, throughSeq: childRows.length - 1 }, signal) expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type'])) - await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) + await expect(scaffold.ctx.sessionController.events({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) .rejects.toThrow() - await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) + await expect(scaffold.ctx.sessionController.events({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) .resolves.toMatchObject({ hasMore: false }) const childWriter = await scaffold.ctx.sessionPersistence.open(childId, 'write') await childWriter.close() diff --git a/apps/web/tests/shared-inspector.e2e.ts b/apps/web/tests/shared-inspector.e2e.ts index d72ff60197..ee0229ebfc 100644 --- a/apps/web/tests/shared-inspector.e2e.ts +++ b/apps/web/tests/shared-inspector.e2e.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { chromium, type Browser } from 'playwright' import { expect, it, onTestFinished } from 'vitest' import { compareOrRefreshGolden, launchWebScaffold, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { experimentalBundleDescription } from './support.ts' const bundle = fileURLToPath(new URL('../../../packages/experimental/inspector-profile', import.meta.url)) const fixture = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v4.jsonl', import.meta.url)) @@ -87,14 +88,14 @@ it('toggles bottom DevTools from the page and iframe without a selected Session' it.each([ { locale: 'zh-CN', file: 'zh', plugins: '插件', open: '查看 开发者工具', bundle: '开发者工具', - description: '查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端', + description: experimentalBundleDescription('inspector-profile', 'zh'), inspector: 'NodeJS 诊断', inspectorDescription: '按 Ctrl/Cmd+Shift+. 在底部面板打开 Chrome DevTools 进行开发调试', session: '会话数据诊断', sessionDescription: '在会话侧边栏启用当前会话的原始数据和聊天分组数据分析功能', openSidebar: '打开右侧边栏', sidebarDescription: '在侧边栏分析当前会话原始日志和聊天分组数据', }, { locale: 'en-US', file: 'en', plugins: 'Plugins', open: 'View Developer tools', bundle: 'Developer tools', - description: 'Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.', + description: experimentalBundleDescription('inspector-profile', 'en'), inspector: 'NodeJS inspector', inspectorDescription: 'Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.', session: 'Session log', sessionDescription: "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.", openSidebar: 'Open right sidebar', sidebarDescription: 'Analyze raw logs and grouped chat messages for the current session in the sidebar.', diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 79fc706ec9..307e1adf12 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -1046,6 +1046,54 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) + it('resists a floating panel dragged past the viewport edges and springs it back inside', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-float-edge')) + const column = await resetSidebar(page) + const floats = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]') + await proseChip(page).click() + await expect.poll(async () => await tabTitles(paneAt(column, 0))).toEqual(['Files', SAMPLE_NAME]) + await floatByDrag(page, paneAt(column, 0).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })) + await expect.poll(async () => await floats.count()).toBe(1) + const panel = floats.first() + const viewport = page.viewportSize() + if (viewport === null) throw new Error('page has no viewport') + + // Carrying the grip to the bottom-right corner asks for the panel well past both edges. + const grip = await centre(panel.locator('[data-dockkit-float-grip]')) + await page.mouse.move(grip.x, grip.y) + await page.mouse.down() + await page.mouse.move(viewport.width - 2, viewport.height - 2, { steps: 8 }) + const pushed = await panel.boundingBox() + if (pushed === null) throw new Error('float is not rendered') + expect(pushed.x + pushed.width).toBeGreaterThan(viewport.width) + expect(pushed.x + pushed.width).toBeLessThan(viewport.width + 120) + expect(pushed.y + pushed.height).toBeGreaterThan(viewport.height) + expect(pushed.y + pushed.height).toBeLessThan(viewport.height + 120) + await page.mouse.up() + + // The return overshoots inward and can pass the edges before it ends, so + // wait for the resting style, which drops the inline position. + await expect.poll(async () => await panel.evaluate(node => (node as HTMLElement).style.left)).toBe('') + const rest = await panel.boundingBox() + if (rest === null) throw new Error('float is not rendered') + expect({ right: Math.round(rest.x + rest.width), bottom: Math.round(rest.y + rest.height) }) + .toEqual({ right: viewport.width, bottom: viewport.height }) + + // At rest the stylesheet clamps on its own: a rectangle published from a + // stale viewport reading, far outside and larger than the viewport, still + // draws inside it. + const clamped = await panel.evaluate((node) => { + const element = node as HTMLElement + for (const name of ['x', 'y', 'width', 'height']) element.style.setProperty(`--dsh-dockkit-float-${name}`, '5000px') + const box = element.getBoundingClientRect() + return { left: box.left, top: box.top, right: box.right, bottom: box.bottom } + }) + expect(clamped).toEqual({ left: 0, top: 0, right: viewport.width, bottom: viewport.height }) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + it('drops a pane whose last tab closes, and follows the last-tab rule on the surface', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle')) const column = await resetSidebar(page) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index ef7d3765c4..953f701fc9 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -1,5 +1,5 @@ // Shared plumbing for the web smoke tests (dist location, free port, failure shots). -import { existsSync, mkdirSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync } from 'node:fs' import { createServer } from 'node:net' import { createRequire } from 'node:module' import { join } from 'node:path' @@ -12,6 +12,17 @@ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.met export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** + * Read the `meta.description` an experimental bundle's plugin page shows for one locale. + * @param bundle - Package directory under `packages/experimental`. + * @param locale - Locale resource name, such as `en` or `zh`. + * @returns The description text from `locale/.json`. + */ +export function experimentalBundleDescription(bundle: string, locale: string): string { + const resource = JSON.parse(readFileSync(join(REPO_ROOT, 'packages/experimental', bundle, 'locale', `${locale}.json`), 'utf8')) as { meta: { description: string } } + return resource.meta.description +} + const installationRequire = createRequire(join(REPO_ROOT, 'apps/cli/package.json')) /** diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index 5e0b163aa1..6788e3f382 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -29,7 +29,8 @@ import SessionTitleService from '@deepseek-ai/dsh-session-title' import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline' import TokenMeter from '@deepseek-ai/dsh-token-meter' // These Host-only adapters have no public Node export and are compiled into the benchmark worker. -import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts' +import { HistoryController } from '../../packages/api/session-controller/src/history/controller.ts' +import { WebController } from '../../packages/api/session-controller/src/web/controller.ts' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts' @@ -151,7 +152,7 @@ class SessionBenchmarkHost { private constructor( private readonly ctx: Context, private readonly scenario: SessionOpenBenchmarkScenario, - private readonly history: SessionHistoryController | undefined, + private readonly history: WebController | undefined, ) {} static async create(root: string, scenario: SessionOpenBenchmarkScenario): Promise { @@ -165,10 +166,10 @@ class SessionBenchmarkHost { } await installProjectionSet(ctx) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' }) - let history: SessionHistoryController | undefined + let history: WebController | undefined if (scenario === 'first-history') { new BenchmarkSessionQuery(ctx) - history = new SessionHistoryController(ctx, (observation) => { + history = new WebController(ctx, new HistoryController(ctx), (observation) => { // First-history ends at snapshot delivery; Agent-resume owns live activation and retention. observation[Symbol.dispose]() }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eda51827f8..f830b152c5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -203,7 +203,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` · `remoteAccess` -- `source`: [`packages/api/gateway/src/index.ts:156`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:158`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -242,8 +242,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:109`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `typertGateway` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:110`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -2490,7 +2490,7 @@ export interface PlanModeConfig { - `inject`: `loader` · `profileContext` - `refs`: [`NpmFileSource`](subsystems/boot.md) -- `source`: [`packages/boot/plugin-manager/src/index.ts:53`](../packages/boot/plugin-manager/src/index.ts) +- `source`: [`packages/boot/plugin-manager/src/index.ts:54`](../packages/boot/plugin-manager/src/index.ts) ```ts config-catalog /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ @@ -2603,8 +2603,10 @@ export interface Config { export interface Config { /** Worker invocation in the subprocess world; omitted selects the local carrier. */ launch?: LaunchConfig - /** per-call starts fresh processes; persistent-in-memory retains a REPL per owner under the same file policy. */ - executionMode?: 'per-call' | 'persistent-in-memory' + /** per-call uses fresh async function bodies; vm-script uses VM contexts; persistent-in-memory retains a REPL per owner. */ + executionMode?: 'per-call' | 'persistent-in-memory' | 'vm-script' + /** Retain a Script process and context per stateOwner; only supported with executionMode: vm-script. */ + scriptPersistence?: boolean /** Default elapsed deadline, including nested tool and approval waits. */ timeoutMs?: number /** Maximum numeric elapsed budget accepted by resolve. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a91d43209c..3cb37e457d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -205,7 +205,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` · `remoteAccess` -- `source`: [`packages/api/gateway/src/index.ts:156`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:158`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -244,8 +244,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:109`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `typertGateway` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:110`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -2492,7 +2492,7 @@ export interface PlanModeConfig { - `inject`: `loader` · `profileContext` - `refs`: [`NpmFileSource`](subsystems/boot.zh.md) -- `source`: [`packages/boot/plugin-manager/src/index.ts:53`](../packages/boot/plugin-manager/src/index.ts) +- `source`: [`packages/boot/plugin-manager/src/index.ts:54`](../packages/boot/plugin-manager/src/index.ts) ```ts config-catalog /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ @@ -2605,8 +2605,10 @@ export interface Config { export interface Config { /** Worker invocation in the subprocess world; omitted selects the local carrier. */ launch?: LaunchConfig - /** per-call starts fresh processes; persistent-in-memory retains a REPL per owner under the same file policy. */ - executionMode?: 'per-call' | 'persistent-in-memory' + /** per-call uses fresh async function bodies; vm-script uses VM contexts; persistent-in-memory retains a REPL per owner. */ + executionMode?: 'per-call' | 'persistent-in-memory' | 'vm-script' + /** Retain a Script process and context per stateOwner; only supported with executionMode: vm-script. */ + scriptPersistence?: boolean /** Default elapsed deadline, including nested tool and approval waits. */ timeoutMs?: number /** Maximum numeric elapsed budget accepted by resolve. */ diff --git a/docs/cookbook/adding-a-plugin-preview.i18n.yaml b/docs/cookbook/adding-a-plugin-preview.i18n.yaml index 0111677ab5..9884dce36b 100644 --- a/docs/cookbook/adding-a-plugin-preview.i18n.yaml +++ b/docs/cookbook/adding-a-plugin-preview.i18n.yaml @@ -3,17 +3,17 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-plugin-preview.md /cookbook-add-a-plugin-feature-preview: - en: c37096cd3cdb886c - zh: fc6850d68c638898 + en: a0db5c6e164abfa1 + zh: 792c831c20a41e5d /cookbook-add-a-plugin-feature-preview/1-decide-whether-an-image-helps: - en: ee84f52d834cbccf - zh: 7bf3a425a231b5e6 + en: 0b401d0094e3b8ae + zh: d9eaedfaf8be012e /cookbook-add-a-plugin-feature-preview/2-draw-one-fixed-size-svg: en: 3a28640aa605dce8 zh: c5336958f92b5a70 -/cookbook-add-a-plugin-feature-preview/3-put-it-at-the-top-of-the-detail-document: - en: 17e2ace32a89c419 - zh: c635a890e86a41ec +/cookbook-add-a-plugin-feature-preview/3-make-it-the-detail-document: + en: ad09702c601e05a6 + zh: ae03649d34ab08b1 /cookbook-add-a-plugin-feature-preview/4-page-behavior: en: 20ce358cb110104c zh: abb8d72fed81f3c8 diff --git a/docs/cookbook/adding-a-plugin-preview.md b/docs/cookbook/adding-a-plugin-preview.md index 1683aab10a..1a2ceb9d81 100644 --- a/docs/cookbook/adding-a-plugin-preview.md +++ b/docs/cookbook/adding-a-plugin-preview.md @@ -2,11 +2,11 @@ English | [中文](adding-a-plugin-preview.zh.md) -A feature preview is an optional illustration at the top of a bundle's detail document, the long description that its page in **Plugins** shows first. Follow this guide when drawing one for an official bundle. The detail document itself is declared through [`meta.detail`](adding-a-package.md#plugin-display-metadata). +A feature preview is an optional illustration in a bundle's detail document, the document that its page in **Plugins** shows below the description. Follow this guide when drawing one for an official bundle. The detail document itself is declared through [`meta.detail`](adding-a-package.md#plugin-display-metadata). ## 1. Decide whether an image helps -Show one concrete result or recognizable capability. Omit the image when the description is enough: no image is required. Essential instructions stay in the document's text or in configuration outside the illustration. +Show one concrete result or recognizable capability. Omit the image when the description is enough: no image is required. Essential instructions stay in the bundle's description or in configuration outside the illustration. ## 2. Draw one fixed-size SVG @@ -21,15 +21,15 @@ Show one concrete result or recognizable capability. Omit the image when the des The SVG is self-contained: no fonts, remote resources, scripts, handlers, animation, stylesheets, `text`, or `foreignObject`. An `image` element may embed an unchanged official PNG/JPEG/WebP brand asset as a base64 data URL. Small plugin icons remain geometry-only. The page renders the SVG through ``. Keep each file small; the Host refuses images above plugin-manager's `detailImageMaxBytes`, 5 MiB by default. -## 3. Put it at the top of the detail document +## 3. Make it the detail document -Save the pair as `docs/preview.svg` and `docs/preview.zh.svg` in the bundle package, beside `docs/DETAIL.md` and `docs/DETAIL.zh.md`, and make the image the first line of each document: +Save the pair as `docs/preview.svg` and `docs/preview.zh.svg` in the bundle package, beside `docs/DETAIL.md` and `docs/DETAIL.zh.md`, and make the image the only line of each document: ```markdown ![Example feature preview](preview.svg) ``` -The Chinese document references `preview.zh.svg` with Chinese alternative text. Use the [badge skill](../../packages/experimental/badge-skill-bundle/docs/DETAIL.md) and [voice input](../../packages/experimental/voice-input-bundle/docs/DETAIL.md) documents as examples. The voice transcript is explanatory sample content, not a recording result. +The Chinese document references `preview.zh.svg` with Chinese alternative text. Use the [badge skill](../../packages/experimental/badge-skill-bundle/docs/DETAIL.md) and [voice input](../../packages/experimental/voice-input-bundle/docs/DETAIL.md) documents as examples. The voice transcript is explanatory sample content, not a recording result. Describe what the bundle does and how to use it, at the level of behavior, in `meta.description`, which the page shows above the image. ## 4. Page behavior diff --git a/docs/cookbook/adding-a-plugin-preview.zh.md b/docs/cookbook/adding-a-plugin-preview.zh.md index 3d3caeb1bf..bc6d208ac3 100644 --- a/docs/cookbook/adding-a-plugin-preview.zh.md +++ b/docs/cookbook/adding-a-plugin-preview.zh.md @@ -2,11 +2,11 @@ [English](adding-a-plugin-preview.md) | 中文 -功能配图是位于组合包详细介绍文档顶部的可选插图;详细介绍文档是该组合包在**插件**中的页面最先显示的长描述。为官方组合包绘制配图时遵循本指引。详细介绍文档本身通过 [`meta.detail`](adding-a-package.zh.md#plugin-display-metadata) 声明。 +功能配图是组合包详细介绍文档中的可选插图;详细介绍文档是该组合包在**插件**中的页面显示在描述下方的文档。为官方组合包绘制配图时遵循本指引。详细介绍文档本身通过 [`meta.detail`](adding-a-package.zh.md#plugin-display-metadata) 声明。 ## 1. 判断是否需要配图 -展示一个具体结果或容易辨认的能力。文字说明足够时省略配图:配图不是必需的。必要操作说明仍放在文档文字或图外的配置中。 +展示一个具体结果或容易辨认的能力。文字说明足够时省略配图:配图不是必需的。必要操作说明仍放在组合包描述或图外的配置中。 ## 2. 绘制一张固定尺寸 SVG @@ -21,15 +21,15 @@ SVG 必须自包含:不使用字体、远程资源、脚本、事件处理器、动画、样式表、`text` 或 `foreignObject`。`image` 元素可以通过 base64 data URL 嵌入未改动的官方 PNG/JPEG/WebP 品牌素材。小插件图标仍仅使用几何图形。页面通过 `` 渲染 SVG。每个文件都应控制大小;Host 会拒绝超过 plugin-manager 的 `detailImageMaxBytes`(默认 5 MiB)的图片。 -## 3. 放在详细介绍文档顶部 +## 3. 用它作为详细介绍文档 -将这对文件保存为组合包中的 `docs/preview.svg` 和 `docs/preview.zh.svg`,与 `docs/DETAIL.md` 和 `docs/DETAIL.zh.md` 放在一起,并把图片作为每份文档的第一行: +将这对文件保存为组合包中的 `docs/preview.svg` 和 `docs/preview.zh.svg`,与 `docs/DETAIL.md` 和 `docs/DETAIL.zh.md` 放在一起,并把图片作为每份文档唯一的一行: ```markdown ![Example feature preview](preview.svg) ``` -中文文档引用 `preview.zh.svg`,并使用中文替代文字。可参考[徽章技能](../../packages/experimental/badge-skill-bundle/docs/DETAIL.md)和[语音输入](../../packages/experimental/voice-input-bundle/docs/DETAIL.md)文档。语音图中的转写文字是说明性示例,不是录音结果。 +中文文档引用 `preview.zh.svg`,并使用中文替代文字。可参考[徽章技能](../../packages/experimental/badge-skill-bundle/docs/DETAIL.md)和[语音输入](../../packages/experimental/voice-input-bundle/docs/DETAIL.md)文档。语音图中的转写文字是说明性示例,不是录音结果。组合包做什么、怎么用,按行为层面写在 `meta.description` 中,页面将其显示在配图上方。 ## 4. 页面行为 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2d09200c42..999b732bc7 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,8 +21,8 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: 8c004ba883c40653 - zh: ec5e679339408daa + en: 12810e1c6ca8b8e4 + zh: b505d34cdf27856d /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 diff --git a/docs/development.md b/docs/development.md index 04af67a5e0..76e34201cb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,6 +66,8 @@ The repository uses isolated Host and Client aggregates. An ordinary package is | `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | | `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | +Host aggregate type checks run with a 8 GiB Node old-space limit across build, release, and Windows diagnostic commands. This is a per-compiler limit; concurrent build steps need additional memory. + Host and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow: - `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope. diff --git a/docs/development.zh.md b/docs/development.zh.md index 20edd33aa0..ac61221c52 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -70,6 +70,8 @@ pnpm run typecheck | `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | | `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` 包 extends。 | 否 | +构建、发布和 Windows 诊断命令中的 Host aggregate 类型检查为 Node 老生代堆设置 8 GiB 上限。这是单个编译进程的上限;并发构建步骤还需要额外内存。 + Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律: - `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f419955c56..9defd245a7 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -6,8 +6,8 @@ en: 41d772192075e133 zh: dda91c6c670cfd9d /event-producer-and-consumer-matrix: - en: 19f30fda81e73345 - zh: a8505006244f5619 + en: 2fac23d1dcc1fd3d + zh: 5fb06020a810adbf /event-producer-and-consumer-matrix/non-harness-or-undeclared-event-strings-seen-in-package-source: en: 96dc0ff1580e5741 zh: 96dc0ff1580e5741 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bff392609e..7cb4b30b18 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -5,6 +5,8 @@ This matrix shows which packages dispatch each harness-owned event and which packages listen to it. Events are many-to-many, so the dense relation data is presented as a table rather than one large graph. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment. +Application listeners are outside this package matrix. Desktop Host subscribes to `plugin-manager/operation` through [`installOperationDiagnostics`](../apps/desktop-host/src/operation-diagnostics.ts) and forwards bounded records to the shell. + | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | @@ -21,12 +23,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:689`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:669`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:696`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:675`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:682`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -55,10 +57,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:394`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:400`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:407`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:414`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:427`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:433`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:440`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:419`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - | +| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:447`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e52837f45c..1e88e78ad2 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -7,6 +7,8 @@ 本矩阵展示哪些包会派发各个 harness 自有事件,以及哪些包会监听这些事件。事件之间存在多对多关系,因此密集的关系数据以表格而非一张大型关系图呈现。接收方和事件名称类型还涵盖有意绕过 `ctx.emit` 的内含派发位置,例如 subagent 生命周期封装。 +应用级监听者不在本包矩阵范围内。Desktop Host 通过 [`installOperationDiagnostics`](../apps/desktop-host/src/operation-diagnostics.ts) 订阅 `plugin-manager/operation`,并向壳转发有界记录。 + | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | @@ -23,12 +25,12 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:689`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:669`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:696`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:675`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:682`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -57,10 +59,11 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:394`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:400`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:407`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:414`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:427`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:433`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:440`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:419`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - | +| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:447`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | diff --git a/docs/module-graph.md b/docs/module-graph.md index f17471eb34..9b0be7bcc8 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -777,9 +777,6 @@ flowchart TD pkg_deepseek_account_platform --> pkg_credentials pkg_deepseek_account_platform --> pkg_deepseek_account pkg_deepseek_account_platform --> pkg_host_webserver - pkg_cua --> pkg_attachment - pkg_cua --> pkg_scope - pkg_cua --> pkg_user_questions pkg_experimental_ptc_runtime_python --> pkg_ptc_runtime pkg_experimental_ptc_runtime_python --> pkg_sandbox pkg_experimental_ptc_runtime_python --> pkg_sandbox_policy @@ -943,6 +940,10 @@ flowchart TD pkg_creation_recommendations --> pkg_session pkg_creation_recommendations --> pkg_skill pkg_creation_recommendations --> pkg_tools + pkg_cua --> pkg_attachment + pkg_cua --> pkg_ptc_runtime_node + pkg_cua --> pkg_scope + pkg_cua --> pkg_user_questions pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_agent pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_browser_use pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_system_prompt @@ -1725,7 +1726,6 @@ flowchart TD | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | -| [`cua`](../packages/experimental/cua) | `experimental` | [`attachment`](../packages/attachment/attachment), [`scope`](../packages/core/scope), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`experimental-worktree`](../packages/experimental/worktree) | `experimental` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`working-directory`](../packages/session/working-directory) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1757,6 +1757,7 @@ flowchart TD | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`action-cards`](../packages/experimental/action-cards) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`cua`](../packages/experimental/cua) | `experimental` | [`attachment`](../packages/attachment/attachment), [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`scope`](../packages/core/scope), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index baf721dc22..098e1d4258 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -779,9 +779,6 @@ flowchart TD pkg_deepseek_account_platform --> pkg_credentials pkg_deepseek_account_platform --> pkg_deepseek_account pkg_deepseek_account_platform --> pkg_host_webserver - pkg_cua --> pkg_attachment - pkg_cua --> pkg_scope - pkg_cua --> pkg_user_questions pkg_experimental_ptc_runtime_python --> pkg_ptc_runtime pkg_experimental_ptc_runtime_python --> pkg_sandbox pkg_experimental_ptc_runtime_python --> pkg_sandbox_policy @@ -945,6 +942,10 @@ flowchart TD pkg_creation_recommendations --> pkg_session pkg_creation_recommendations --> pkg_skill pkg_creation_recommendations --> pkg_tools + pkg_cua --> pkg_attachment + pkg_cua --> pkg_ptc_runtime_node + pkg_cua --> pkg_scope + pkg_cua --> pkg_user_questions pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_agent pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_browser_use pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_system_prompt @@ -1727,7 +1728,6 @@ flowchart TD | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | -| [`cua`](../packages/experimental/cua) | `experimental` | [`attachment`](../packages/attachment/attachment), [`scope`](../packages/core/scope), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`experimental-worktree`](../packages/experimental/worktree) | `experimental` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`working-directory`](../packages/session/working-directory) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1759,6 +1759,7 @@ flowchart TD | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`action-cards`](../packages/experimental/action-cards) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`cua`](../packages/experimental/cua) | `experimental` | [`attachment`](../packages/attachment/attachment), [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`scope`](../packages/core/scope), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 0fecbb8961..4ec686df63 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1350,8 +1350,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: a9d7dc010ada133d - zh: 9bd682adee72d04e + en: 1e12d380c4988d40 + zh: 14a781104e00f360 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1857,8 +1857,8 @@ en: 5a0bbf4c76266ac5 zh: 0b20854f90f9d74d /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 81620af099525ee9 - zh: 812db2fdf9e5d309 + en: e98b716228feb157 + zh: 2bb593ae9cd0ce0d /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 74a7956486..8dbd3621ac 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5600,7 +5600,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7656,7 +7656,7 @@ Sources: [`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/inde SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:395`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 7b4f09039e..e28c70c102 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5602,7 +5602,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7658,7 +7658,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:395`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 247fb463f6..d70bd19123 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -249,7 +249,7 @@ {"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]}, {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:198"]}, - {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:380","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:386","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, {"digest":"4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c","names":["packages/llm/llm/src/types.ts#TextBlock"],"sources":["packages/llm/llm/src/types.ts:62","packages/web/web-search-deepseek/src/provider.ts:66"]}, @@ -333,7 +333,7 @@ {"digest":"6ccb6bc423fb3952458abf9c1b1c3284208b7e660344b612cc55da773d61afe4","names":[],"sources":[]}, {"digest":"6cda0d6d3b4336d83998fe580628426df81c2afbab5d768b8813b56d569120e8","names":[],"sources":[]}, {"digest":"6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65","names":[],"sources":[]}, - {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:389"]}, + {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:395"]}, {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:31"]}, {"digest":"6ea608bdc8a7a63d3d23b84c49721325094db88d136482e43a820945295eee3e","names":[],"sources":[]}, {"digest":"6ebcc2dbec9f2dabca9f668ae6d39e877899b180740570f453409db339221df3","names":[],"sources":[]}, diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 621444176d..856a932e75 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -6,8 +6,8 @@ en: 564dfcaaca384e23 zh: 5d215635919b5bc4 /durable-attachments/identity-and-verified-metadata: - en: 96378c208b220c6f - zh: 7af93f6a79520732 + en: 3e14f535f7587b0c + zh: cbbd513a5dff5fed /durable-attachments/commit-and-verified-read-payloads: - en: f79753272bb0ec70 - zh: 61830c41021f9274 + en: db2017cdf91e260a + zh: 6ba39bbc7cef4d8f diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 2e330672f7..bccf3c33a3 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -58,7 +58,7 @@ interface ImageAttachmentLimits { The local backend admits at most 20 images and 200 MiB of encoded source data per message. One source may use up to 50 MiB, 64,000,000 pixels, and 65,535 pixels on either side. These source limits precede the independent normalization stage, which limits the long edge to 2048 pixels and encoded data to 4 MiB by default. -The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first, while every authoritative read still re-checks digest, media signature, dimensions, and metadata against the object. +The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first. Reads verify the stored object against the supplied reference's ID, format, byte length, width, and height. Optional display names and pre-normalization dimensions are returned unchanged as caller-supplied display information, without verification against stored bytes. ## Commit and verified-read payloads @@ -173,7 +173,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after upload receipt resolution, replaces base64 image uploads with durable references, passes durable file references and already stored images unchanged, and counts every image toward the per-message image count and aggregate byte limits, a stored image by the bytes of its upload. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version at an exact route-chosen target size and encoded-byte target. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after upload receipt resolution, replaces base64 image uploads with durable references, passes durable file references and already stored images unchanged, and counts every image toward the per-message image count and aggregate byte limits, a stored image by the bytes of its upload. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized object against the supplied complete reference without consulting Session history, then returns that reference with the bytes. Access policy belongs to the calling API; Session Controller uses the store-level [image access rule](../../.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md). `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version at an exact route-chosen target size and encoded-byte target. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -243,8 +243,8 @@ isAttachmentError(error: unknown): error is AttachmentError abstract saveImage(input: SaveImageAttachment): Promise /** - * Read one image and verify that bytes still match the recorded reference. - * @param ref - durable reference from the session log. + * Read one image and verify its ID, format, byte length, and dimensions against the supplied reference. + * @param ref - supplied image reference; name and originalDimensions are preserved without file verification. * @param signal - optional cancellation for backend read and verification work. * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. @@ -282,7 +282,7 @@ saveFileStream(input: SaveFileStreamAttachment): Promise * Read and verify one verbatim stored file as bounded chunks. Providers must * not collect the complete file in memory. Backends without verbatim file * reads keep this default rejection. - * @param ref - durable reference from the session log. + * @param ref - durable file reference from the session log. * @param signal - optional cancellation for backend reads and verification work. * @returns exact file bytes in order; integrity failures reject the iteration. */ diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 057ed080f4..c917947b73 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -58,7 +58,7 @@ interface ImageAttachmentLimits { 本地后端每条消息最多准入 20 张图片,源图编码数据总量不超过 200 MiB。单张源图不得超过 50 MiB、64,000,000 像素和单边 65,535 像素。这些源文件限制先于独立的规范化阶段执行;该阶段默认把长边限制为 2048 像素,把编码数据限制为 4 MiB。 -引用记录固有尺寸和编码长度,使客户端无需先解码即可排布历史记录;每次权威读取仍会根据对象重新校验摘要、媒体签名、尺寸和元数据。 +引用记录固有尺寸和编码长度,使客户端无需先解码即可排布历史记录。读取会把存储对象与传入引用中的 ID、格式、字节数、宽度和高度比对。可选的显示名称和规范化前尺寸作为调用方提交的显示信息原样返回,不根据存储字节校验。 ## 提交与经校验读取的数据 @@ -173,7 +173,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在上传凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,让持久文件引用和已存储的图片原样通过,并把每张图片计入单条消息的图片数量与总字节上限,已存储的图片按其上传的字节数计入。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切的路由目标尺寸和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在上传凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,让持久文件引用和已存储的图片原样通过,并把每张图片计入单条消息的图片数量与总字节上限,已存储的图片按其上传的字节数计入。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 把规范化对象与传入的完整引用比对,无需查询 Session 历史,随后返回该引用与字节。访问规则由调用 API 负责;Session Controller 使用存储范围的[图片访问规则](../../.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.zh.md)。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切的路由目标尺寸和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -243,8 +243,8 @@ isAttachmentError(error: unknown): error is AttachmentError abstract saveImage(input: SaveImageAttachment): Promise /** - * Read one image and verify that bytes still match the recorded reference. - * @param ref - durable reference from the session log. + * Read one image and verify its ID, format, byte length, and dimensions against the supplied reference. + * @param ref - supplied image reference; name and originalDimensions are preserved without file verification. * @param signal - optional cancellation for backend read and verification work. * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. @@ -282,7 +282,7 @@ saveFileStream(input: SaveFileStreamAttachment): Promise * Read and verify one verbatim stored file as bounded chunks. Providers must * not collect the complete file in memory. Backends without verbatim file * reads keep this default rejection. - * @param ref - durable reference from the session log. + * @param ref - durable file reference from the session log. * @param signal - optional cancellation for backend reads and verification work. * @returns exact file bytes in order; integrity failures reject the iteration. */ diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 1d127bd772..157ef5da64 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -563,6 +563,23 @@ An installation moved between its Host phases. `installing` is announced once pe Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + + +#### `plugin-manager/operation` — parallel + +One manager invocation started or ended; subscribers run independently without delaying management. + +```ts cordis-catalog +/** + * One manager invocation started or ended; subscribers run independently without delaying management. + * @mode parallel + * @param operation - Correlated operation facts; a start without an end has an unknown outcome. + */ +'plugin-manager/operation'(operation: PluginOperation): void | Promise +``` + +Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + #### `plugin-manager/update-state` — emit diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 80395ce86d..1e5f78cf0e 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -563,6 +563,23 @@ An installation moved between its Host phases. `installing` is announced once pe Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + + +#### `plugin-manager/operation` — parallel + +One manager invocation started or ended; subscribers run independently without delaying management. + +```ts cordis-catalog +/** + * One manager invocation started or ended; subscribers run independently without delaying management. + * @mode parallel + * @param operation - Correlated operation facts; a start without an end has an unknown outcome. + */ +'plugin-manager/operation'(operation: PluginOperation): void | Promise +``` + +Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + #### `plugin-manager/update-state` — emit diff --git a/docs/subsystems/ptc-runtime.i18n.yaml b/docs/subsystems/ptc-runtime.i18n.yaml index 7c6f634157..b9a617739e 100644 --- a/docs/subsystems/ptc-runtime.i18n.yaml +++ b/docs/subsystems/ptc-runtime.i18n.yaml @@ -6,8 +6,8 @@ en: 337a6580d7571274 zh: f4ae98899120d122 /ptc-runtime/the-run-request-in-result-out: - en: 0305ed1d4f8bc618 - zh: fcfeb156574b5779 + en: 6dfa7da8b13a4f17 + zh: 09a3c8c4922239ef /ptc-runtime/bindings-host-functions-as-program-globals: en: 182147ddf388c600 zh: c0a7d77602c78bcd diff --git a/docs/subsystems/ptc-runtime.md b/docs/subsystems/ptc-runtime.md index d9ad8138a0..b1820bc264 100644 --- a/docs/subsystems/ptc-runtime.md +++ b/docs/subsystems/ptc-runtime.md @@ -19,9 +19,11 @@ interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It * follows the provider's programFormat: an async function body supports - * top-level `await` and `return`; a REPL supports top-level declarations, - * `await`, and a final expression. The completion value becomes - * {@link PtcRunResult.value}. + * top-level `await` and `return`; REPL and Script programs support top-level + * declarations, `await`, and a final expression, but not top-level `return`. + * The Script provider lowers top-level await; its executionInstructions and + * documentation define retained-binding semantics. The completion value + * becomes {@link PtcRunResult.value}. */ program: string /** @@ -84,7 +86,7 @@ interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (a return or final REPL expression), when it ran to + * The program's completion value (a return or final REPL/Script expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. @@ -101,7 +103,12 @@ interface PtcRunResult { } ``` -`programFormat` distinguishes async function bodies from REPL statements. `stateOwner` is an in-process object identity for optional retained memory; distinct objects remain isolated. Consumers await `release(owner)` when that owner ends; cleanup failures reject that call. Omitted owners are one-shot, and stateless providers ignore ownership. The [persistent Node provider](../../packages/ptc-runtime/ptc-runtime-node/README.md#persistent-repl) defines reset and recovery behavior; no retained object enters the durable Session format. +```ts type-equiv +/** Source evaluation form, independent of retained program state. */ +type PtcProgramFormat = 'function-body' | 'repl' | 'script' +``` + +`programFormat` distinguishes async function bodies, REPL statements, and scripts. REPL and Script programs accept top-level `await`, declarations, and a final expression; top-level `return` is not supported. The Node Script provider lowers top-level await and awaits the completion value; its [declaration limitations](../../packages/ptc-runtime/ptc-runtime-node/README.md#known-limitations-and-deferred-work) apply to transformed programs. `retainsState` reports memory retention independently of source syntax. `stateOwner` is an in-process object identity for optional retained memory; distinct objects remain isolated. Consumers await `release(owner)` when that owner ends; cleanup failures reject that call. Omitted owners are one-shot, and stateless providers ignore ownership. The [persistent Node provider](../../packages/ptc-runtime/ptc-runtime-node/README.md#persistent-repl) defines reset and recovery behavior; no retained object enters the durable Session format. ## Bindings: host functions as program globals diff --git a/docs/subsystems/ptc-runtime.zh.md b/docs/subsystems/ptc-runtime.zh.md index ba747b5369..9aee80d258 100644 --- a/docs/subsystems/ptc-runtime.zh.md +++ b/docs/subsystems/ptc-runtime.zh.md @@ -19,9 +19,11 @@ interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It * follows the provider's programFormat: an async function body supports - * top-level `await` and `return`; a REPL supports top-level declarations, - * `await`, and a final expression. The completion value becomes - * {@link PtcRunResult.value}. + * top-level `await` and `return`; REPL and Script programs support top-level + * declarations, `await`, and a final expression, but not top-level `return`. + * The Script provider lowers top-level await; its executionInstructions and + * documentation define retained-binding semantics. The completion value + * becomes {@link PtcRunResult.value}. */ program: string /** @@ -84,7 +86,7 @@ interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (a return or final REPL expression), when it ran to + * The program's completion value (a return or final REPL/Script expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. @@ -101,7 +103,12 @@ interface PtcRunResult { } ``` -`programFormat` 区分异步函数体与 REPL 语句。`stateOwner` 是标识可选保留内存的进程内对象身份;不同对象相互隔离。消费者在所有者结束时等待 `release(owner)`;清理失败时该调用拒绝。省略所有者时仅执行一次,无状态提供方忽略所有权。[持久 Node 提供方](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md#persistent-repl)定义重置和恢复行为;保留对象不会进入持久 Session 格式。 +```ts type-equiv +/** Source evaluation form, independent of retained program state. */ +type PtcProgramFormat = 'function-body' | 'repl' | 'script' +``` + +`programFormat` 区分异步函数体、REPL 语句与脚本。REPL 与 Script 程序接受顶层 `await`、声明和末尾表达式,不支持顶层 `return`。Node Script 提供方转换顶层 await 并等待完成值;其[声明限制](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md#known-limitations-and-deferred-work)适用于转换后的程序。`retainsState` 独立于源码语法报告是否保留内存。`stateOwner` 是标识可选保留内存的进程内对象身份;不同对象相互隔离。消费者在所有者结束时等待 `release(owner)`;清理失败时该调用拒绝。省略所有者时仅执行一次,无状态提供方忽略所有权。[持久 Node 提供方](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md#persistent-repl)定义重置和恢复行为;保留对象不会进入持久 Session 格式。 ## 绑定:宿主函数作为程序全局变量 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 4e1ef23338..7cbc91298d 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -938,8 +938,8 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise /** - * Read one image proven reachable from the addressed Session log. - * @param request - Session and attachment identities used for authorization. + * Read one image from the attachment store after gateway authorization. + * @param request - image reference and gateway permission scope; Session existence is not checked. * @returns the durable attachment reference and base64-encoded bytes. */ @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise @@ -960,11 +960,51 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | /** * Read one cold-safe, message-aligned Session history page. - * @param request - durable address, backward cursor, and page budget. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page. + * @returns one chronological page with its inclusive cursor. */ -@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote events(request: SessionPageRequest, signal: AbortSignal): Promise + +/** + * Subscribe to Session events without reading a snapshot. + * @param request - Session address. + * @param signal - subscription cancellation. + * @returns readiness and subsequent events, without a snapshot. + */ +@Remote({ mode: 'stream' }) observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable + +/** + * Subscribe to sparse Session projection changes. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns readiness and sparse projection changes. + */ +@Remote({ mode: 'stream' }) observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable + +/** + * Read passive Session state. + * @param request - target Session. + * @param signal - read cancellation. + * @returns current passive state. + */ +@Remote state(request: SessionStateRequest, signal: AbortSignal): Promise + +/** + * Subscribe to complete Session state replacements. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns complete passive state replacements. + */ +@Remote({ mode: 'stream' }) observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable + +/** + * Wait for Session quiescence or pending input with bounded assistant output. + * @param request - Session, wait bounds and output window. + * @param signal - caller cancellation. + * @returns stopping state and bounded assistant output. + */ +@Remote wait(request: SessionWaitRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -981,7 +1021,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for the Session read. * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ -@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise +@Remote('projections') projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise /** * Stream a complete live-control baseline followed by replacement frames. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 57e732d08c..1f40042967 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -942,8 +942,8 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise /** - * Read one image proven reachable from the addressed Session log. - * @param request - Session and attachment identities used for authorization. + * Read one image from the attachment store after gateway authorization. + * @param request - image reference and gateway permission scope; Session existence is not checked. * @returns the durable attachment reference and base64-encoded bytes. */ @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise @@ -964,11 +964,51 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | /** * Read one cold-safe, message-aligned Session history page. - * @param request - durable address, backward cursor, and page budget. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page. + * @returns one chronological page with its inclusive cursor. */ -@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote events(request: SessionPageRequest, signal: AbortSignal): Promise + +/** + * Subscribe to Session events without reading a snapshot. + * @param request - Session address. + * @param signal - subscription cancellation. + * @returns readiness and subsequent events, without a snapshot. + */ +@Remote({ mode: 'stream' }) observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable + +/** + * Subscribe to sparse Session projection changes. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns readiness and sparse projection changes. + */ +@Remote({ mode: 'stream' }) observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable + +/** + * Read passive Session state. + * @param request - target Session. + * @param signal - read cancellation. + * @returns current passive state. + */ +@Remote state(request: SessionStateRequest, signal: AbortSignal): Promise + +/** + * Subscribe to complete Session state replacements. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns complete passive state replacements. + */ +@Remote({ mode: 'stream' }) observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable + +/** + * Wait for Session quiescence or pending input with bounded assistant output. + * @param request - Session, wait bounds and output window. + * @param signal - caller cancellation. + * @returns stopping state and bounded assistant output. + */ +@Remote wait(request: SessionWaitRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -985,7 +1025,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for the Session read. * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ -@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise +@Remote('projections') projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise /** * Stream a complete live-control baseline followed by replacement frames. diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 9838c8c82d..1fe31a9918 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -444,6 +444,8 @@ interface TypertGateway { * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean + /** @returns detached metadata for all unsettled forwarded events in arrival order. */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. @@ -664,6 +666,12 @@ Resolve strict generated definitions or conservative SRC markers against current */ hasLiveClient(): boolean +/** + * Inspect unsettled forwarded events without changing their lifetime. + * @returns detached event names and Agent identities. + */ +getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] + /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 2cbdaf4a2c..840825979e 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -444,6 +444,8 @@ interface TypertGateway { * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean + /** @returns detached metadata for all unsettled forwarded events in arrival order. */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. @@ -664,6 +666,12 @@ Resolve strict generated definitions or conservative SRC markers against current */ hasLiveClient(): boolean +/** + * Inspect unsettled forwarded events without changing their lifetime. + * @returns detached event names and Agent identities. + */ +getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] + /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.i18n.yaml new file mode 100644 index 0000000000..97b26e625d --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.md +/: + en: 2503e2027c918185 + zh: ffd1bf229fdfbb7d +/handle-script-programs-and-explicit-state-retention: + en: 484afb11caae52a3 + zh: e2c5e8a365845828 +/handle-script-programs-and-explicit-state-retention/change: + en: e8631aefd51606cd + zh: 39cffa3d535b903e +/handle-script-programs-and-explicit-state-retention/migration: + en: 2f9132751d5be30e + zh: c6bc98904eff22ce diff --git a/docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.md new file mode 100644 index 0000000000..25a4c77a18 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "PTC programFormat includes Script syntax, and consumers read retainsState for cross-call memory." +--- + +# Handle Script programs and explicit state retention + +English | [中文](guide.zh.md) + +## Change + +The published `@deepseek-ai/dsh-ptc-runtime` service widens `PtcRuntime.programFormat` from `'function-body' | 'repl'` to include `'script'`. External consumers that exhaustively match the old union must handle the new value. `retainsState` reports whether a provider preserves memory for a `stateOwner`; its base implementation returns `false`, so custom retained providers must override it. + +Node keeps `per-call` as its default. The opt-in `executionMode: vm-script` accepts Script statements and top-level await, with `scriptPersistence` selecting cross-call memory independently. Existing Node function-body and REPL configurations remain valid. + +## Migration + +1. Import the exported `PtcProgramFormat` from `@deepseek-ai/dsh-ptc-runtime` instead of copying its union literals. Update `programFormat` switches in custom tool or workflow adapters. Handle `'script'` using statements or a final expression, without top-level `return`; consult the [Node syntax reference](../../../../packages/ptc-runtime/ptc-runtime-node/README.md#use-this-package). +2. Replace checks that infer memory retention from `programFormat === 'repl'` with `runtime.retainsState`. If a custom `PtcRuntime` provider retains owner-scoped memory, override `retainsState` to return `true` and continue implementing `release(owner)`. +3. Rebuild external consumers against the new package declarations. Confirm that each supported format compiles and that persistence controls follow `retainsState` for both REPL and Script providers. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.zh.md new file mode 100644 index 0000000000..e5f28af66a --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/ptc-script-format/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "PTC programFormat 新增 Script 语法,消费者通过 retainsState 判断跨调用内存保留。" +--- + +# 处理 Script 程序与显式状态保留 + +[English](guide.md) | 中文 + +## 变更 + +已发布的 `@deepseek-ai/dsh-ptc-runtime` 服务将 `PtcRuntime.programFormat` 从 `'function-body' | 'repl'` 扩展为包含 `'script'`。对旧联合类型做穷举匹配的外部消费者必须处理新值。`retainsState` 表示提供方是否为 `stateOwner` 保留内存;基类实现返回 `false`,因此自定义保留状态的提供方必须覆盖该 getter。 + +Node 仍默认使用 `per-call`。显式启用的 `executionMode: vm-script` 接受 Script 语句和顶层 await,`scriptPersistence` 独立选择是否跨调用保留内存。已有 Node 函数体和 REPL 配置仍然有效。 + +## 迁移 + +1. 从 `@deepseek-ai/dsh-ptc-runtime` 导入已导出的 `PtcProgramFormat`,不复制其联合类型字面量。更新自定义工具或工作流适配器中的 `programFormat` 分支。使用语句或末尾表达式处理 `'script'`,不使用顶层 `return`;参见 [Node 语法参考](../../../../packages/ptc-runtime/ptc-runtime-node/README.zh.md#use-this-package)。 +2. 将通过 `programFormat === 'repl'` 推断内存保留的检查改为读取 `runtime.retainsState`。自定义 `PtcRuntime` 提供方若按所有者保留内存,应覆盖 `retainsState` 并返回 `true`,同时继续实现 `release(owner)`。 +3. 使用新的包声明重新构建外部消费者。确认所有支持的格式均可编译,并且持久化控制对 REPL 和 Script 提供方都按 `retainsState` 判断。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.i18n.yaml new file mode 100644 index 0000000000..efe907e2eb --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.md +/: + en: 1e002ad3fd9b1e79 + zh: aeba3ee1e74fa2ae +/image-read-requests-carry-the-complete-attachment-reference: + en: df690494e7cec0b8 + zh: e21d937f2f305d8e +/image-read-requests-carry-the-complete-attachment-reference/change: + en: cca17091c60b9757 + zh: 8f300ab9d0b870d8 +/image-read-requests-carry-the-complete-attachment-reference/migration: + en: b593e623619ea508 + zh: 41714f50a249f941 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.md new file mode 100644 index 0000000000..3aac18a52f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "Image read requests require a complete attachment reference and no longer check Session membership." +--- + +# Image read requests carry the complete attachment reference + +English | [中文](guide.zh.md) + +## Change + +In 0.2.1-rc.3, `session/attachment` accepted `{ sessionId, attachmentId }` and searched the requested Session's history for the complete image reference. It now requires `{ sessionId, attachment: ref }`, where `ref` is an `ImageAttachmentRef` containing `attachmentId`, `mediaType`, `bytes`, `width`, and `height`. The Client's `session.readAttachment()` also takes the complete reference instead of an ID. + +The Host passes this reference to the existing `AttachmentStore.readImage(ref)`. It verifies the ID, actual format, byte length, width, and height against the stored object. The response remains `{ attachment, data }`, with base64 bytes in `data`. It preserves the supplied reference, including optional `name` and `originalDimensions`; those two fields are caller-supplied display information and are not verified against the file. Missing, corrupt, and mismatched objects fail explicitly. + +Connection authentication and Gateway permission checks still apply. Any identity authorized to call the endpoint for the supplied `sessionId` may read an image in the same store, including one referenced only by another Session or by no Session. The handler does not check image membership or Session existence and no longer returns `session/not-found` for an absent Session after authorization. + +## Migration + +1. Update wire calls from `{ sessionId, attachmentId: ref.attachmentId }` to `{ sessionId, attachment: ref }`, using the complete reference from the message image block. Update Client calls from `session.readAttachment(ref.attachmentId)` to `session.readAttachment(ref)`. +2. Treat returned `name` and `originalDimensions` as the caller's display information. Only the ID, media type, byte length, width, and height are checked against the stored object. +3. If your deployment depends on Session membership to isolate images, run separate Host instances with separate `DSH_HOME` directories before upgrading. The shipped composition does not select separate stores by Session within one Host. Gateway permissions govern endpoint invocation, not the image's Session membership. +4. Confirm that an allowed request with a complete reference can read an image absent from the supplied Session, including when that Session does not exist. Denied Gateway requests, missing images, and references with mismatched intrinsic metadata must fail. Existing Session logs and stored images need no migration. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.zh.md new file mode 100644 index 0000000000..d4d3a2e2b6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.zh.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "图片读取请求必须携带完整附件引用,并且不再检查 Session 关联。" +--- + +# 图片读取请求需携带完整附件引用 + +[English](guide.md) | 中文 + +## 变更 + +在 0.2.1-rc.3 中,`session/attachment` 接收 `{ sessionId, attachmentId }`,并在请求中的 Session 历史里查找完整图片引用。现在,请求必须使用 `{ sessionId, attachment: ref }`,其中 `ref` 是包含 `attachmentId`、`mediaType`、`bytes`、`width` 和 `height` 的 `ImageAttachmentRef`。Client 的 `session.readAttachment()` 也改为接收完整引用,不再只接收 ID。 + +Host 把该引用交给已有的 `AttachmentStore.readImage(ref)`,将 ID、实际格式、字节数、宽度和高度与存储对象校验。响应仍为 `{ attachment, data }`,`data` 包含 base64 字节。响应保留传入引用,包括可选的 `name` 和 `originalDimensions`;这两个字段是调用方提交的显示信息,不根据文件校验。对象缺失、损坏或与引用不符时会明确失败。 + +连接身份认证和 Gateway 权限检查仍然适用。任何有权为请求中的 `sessionId` 调用该接口的身份,都可以读取同一存储中的图片,包括仅被其他 Session 引用或未被任何 Session 引用的图片。处理函数不检查图片关联或 Session 是否存在,授权通过后不再因 Session 不存在而返回 `session/not-found`。 + +## 迁移 + +1. 把 wire 调用从 `{ sessionId, attachmentId: ref.attachmentId }` 改为 `{ sessionId, attachment: ref }`,使用消息图片块中的完整引用。把 Client 调用从 `session.readAttachment(ref.attachmentId)` 改为 `session.readAttachment(ref)`。 +2. 把返回的 `name` 和 `originalDimensions` 视为调用方的显示信息。只有 ID、媒体类型、字节数、宽度和高度会与存储对象校验。 +3. 如果部署依赖 Session 关联来隔离图片,请在升级前改用独立 Host 实例及各自的 `DSH_HOME` 目录。随附组合不会在一个 Host 内按 Session 选择独立存储。Gateway 权限约束接口调用,不约束图片的 Session 关联。 +4. 确认获准调用的请求可以凭完整引用读取其 Session 中没有记录的图片,即使该 Session 不存在也可以读取。Gateway 拒绝的请求、缺失图片和固有元数据不符的引用必须失败。已有 Session 日志和存储图片无需迁移。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.i18n.yaml new file mode 100644 index 0000000000..8e84c0dcab --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md +/: + en: 789ee06aa8932a32 + zh: d465eb26824f2b4b +/request-activation-when-following-a-session: + en: 25bd54a8216b1c71 + zh: edaa12b04713528a +/request-activation-when-following-a-session/change: + en: d4021672fedcbfb7 + zh: 35d5e76a69301e4a +/request-activation-when-following-a-session/migration: + en: aa22d72ca3f91d38 + zh: ebe991eca4bf74c2 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md new file mode 100644 index 0000000000..cb01f8e90e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session follow callers must explicitly request background Agent activation." +--- +# Request activation when following a Session + +English | [中文](guide.zh.md) + +## Change + +`session.follow` no longer automatically resumes a cold ordinary Agent when `activate` is omitted. It reads the opening snapshot and follows subsequent events without activation. This affects integrations that used opening a history stream to make an Agent available. The shipped Web Client sends `activate: true` explicitly and retains its opening behavior. + +With `activate: true`, activation starts once the opening snapshot is computed, before it is delivered; that snapshot does not acknowledge Agent readiness. Subagent Sessions still do not use ordinary Agent activation. + +## Migration + +1. Add `activate: true` to `session.follow` requests that must resume a cold ordinary Agent. Keep the existing `address` and history-window fields. +2. For passive viewers, omit `activate` or set it to `false`. +3. Verify that an activation-enabled stream still returns its opening snapshot, then check Agent availability separately. Do not treat receipt of the snapshot as completion of activation. + +See the [Session Controller reference](../../../../packages/api/session-controller/README.md) for history and state observation behavior. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.zh.md new file mode 100644 index 0000000000..5df6e523a3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session follow 调用方必须显式请求后台 Agent 激活。" +--- +# 跟随 Session 时显式请求激活 + +[English](guide.md) | 中文 + +## 变更 + +`session.follow` 在省略 `activate` 时不再自动恢复冷普通 Agent。它只读取开场快照并跟随后续事件,不进行激活。这会影响通过打开历史流使 Agent 可用的集成。随产品提供的 Web Client 显式发送 `activate: true`,保留原有打开行为。 + +设置 `activate: true` 时,激活在开场快照计算完成后、交付之前开始;该快照不确认 Agent 已就绪。子会话仍不走普通 Agent 激活。 + +## 迁移 + +1. 为需要恢复冷普通 Agent 的 `session.follow` 请求添加 `activate: true`,保留原有 `address` 和历史窗口字段。 +2. 被动查看器可以省略 `activate` 或设为 `false`。 +3. 确认启用激活的流仍返回开场快照,再单独检查 Agent 是否可用。不要把收到快照当作激活完成。 + +历史与状态观察行为见 [Session Controller 参考](../../../../packages/api/session-controller/README.zh.md)。 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index a9ddbc70b5..eb3352e60d 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -15,8 +15,8 @@ en: d9859219cdb0f367 zh: 2a3cfa8cb786e327 /deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway: - en: b35573c6707d00d9 - zh: 619bbba900b0fb8f + en: 6ee072ffebd2eb38 + zh: 6a8723d13f443d51 /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: 0916113cdbe794fb zh: 14893c2cba95bfd6 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 6518441122..fea754c891 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -34,6 +34,8 @@ Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `pee Resources are read from validated arguments: Typert-compiled codecs drop undeclared object fields, while SRC fallback checks JSON safety without stripping them; lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's declaration in `typert.resources` supplies either a default field or a synchronous resolver. A field reads a top-level argument of that name or, failing that, the first object argument. A resolver is called once with `{ args, parameters }`: named validated wire values and ordered parameter descriptions containing `name` and `wire`. Both defaults return one identity or `undefined`; arrays are invalid. An explicit reader may return several identities. A resolver or reader that throws, a reader returning an empty list, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. +`getPendingRemoteEvents()` returns detached event-name and Agent-ID metadata for currently unsettled forwarded events in arrival order. It neither exposes request bodies nor changes event lifetimes, and it does not provide a change notification. + A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A Pong, any received frame, or a downlink write that does not complete at once counts as peer activity; with the default `'deflate'`, a frame whose JSON text reaches the compression threshold is deflated asynchronously, so its completion always counts. A socket with no activity across two Ping intervals is terminated, so a congested link that delays the Pong behind data stays connected. With `websocketCompression` at its `'deflate'` default, an upgrade that offers `permessage-deflate` negotiates the extension, and the Host compresses its own outbound frames whose JSON text reaches 1,024 characters; shorter frames stay uncompressed, uplink frames are compressed at the peer's discretion, and `'none'` accepts the same upgrades without the extension. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 34759301dd..1dad623204 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -34,6 +34,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 资源从已校验参数中读取:Typert 编译 codec 会移除未声明的对象字段,SRC fallback 只检查 JSON 安全性而不移除字段;lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则使用该种类在 `typert.resources` 中登记的默认字段或同步 resolver。字段规则读取同名顶层参数,没有时读取第一个对象参数的同名字段。resolver 以 `{ args, parameters }` 调用一次:命名的已校验 wire 值,以及包含 `name`、`wire` 的有序参数描述。两种默认规则均返回单个身份或 `undefined`,数组无效。显式读取函数可以返回多个身份。resolver 或读取函数抛错、读取函数返回空列表、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 +`getPendingRemoteEvents()` 按到达顺序返回当前未结算转发事件的独立元数据,仅含事件名和 Agent ID。它不暴露请求正文,不改变事件生命周期,也不提供变化通知。 + 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。Pong、收到的任意帧,或未一次性完成的下行写入,都视为对端活动;默认 `'deflate'` 下,JSON 文本达到压缩阈值的帧异步 deflate,因此其完成总是计入。连续两个 Ping 间隔没有活动的 socket 会被终止,因此 Pong 被数据阻塞在拥塞链路上时连接仍会保持。`websocketCompression` 保持默认的 `'deflate'` 时,携带 `permessage-deflate` 的升级会协商该扩展,Host 压缩自己发出的、JSON 文本达到 1024 个字符 的帧;更短的帧不压缩,上行帧是否压缩由对端决定,设为 `'none'` 时接受同样的升级但不带该扩展。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 878c77cf5e..4fe2652671 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -36,6 +36,7 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import type { InvokeRemoteRequest, + PendingRemoteEventInfo, TypertGateway, TypertGatewayErrorCode, TypertGatewayWireStream, @@ -70,6 +71,7 @@ import { export type { InvokeRemoteRequest, + PendingRemoteEventInfo, TypertGateway, TypertGatewayErrorCode, TypertGatewayWireStream, @@ -333,6 +335,14 @@ export class TypertGatewayService extends Service implements TypertGateway { return false } + /** + * Inspect unsettled forwarded events without changing their lifetime. + * @returns detached event names and Agent identities. + */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] { + return [...this.pendingRemoteEvents.values()].map(({ frame }) => ({ event: frame.event, agentId: frame.agentId })) + } + /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 83ae7f0730..b802aba061 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -5,7 +5,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { PeerScope, RemoteUplinkWindow, TypertResourceReader } from '@deepseek-ai/dsh-typert-protocol' -import type { RemoteEventHostInfo } from './stream-protocol.ts' +import type { RemoteEventAgentId, RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ export interface InvokeRemoteRequest { @@ -144,6 +144,12 @@ export type TypertGatewayErrorCode = | 'gateway/signature-invalid' | 'gateway/uplink-overflow' +/** Public metadata of one unsettled forwarded Remote event. */ +export interface PendingRemoteEventInfo { + readonly event: string + readonly agentId: RemoteEventAgentId +} + /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { /** Carrier adapter shared by WebSocket and in-process transports. */ @@ -155,6 +161,9 @@ export interface TypertGateway { */ hasLiveClient(): boolean + /** @returns detached metadata for all unsettled forwarded events in arrival order. */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] + /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts index 53727804ee..346f50a8be 100644 --- a/packages/api/gateway/tests/gateway-access.host.spec.ts +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -730,6 +730,7 @@ describe('Gateway forwarded events for non-owner Peers', () => { await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'ready', host: { home: '/fixture' } } }) expect(ctx.typertGateway.hasLiveClient()).toBe(true) + expect(ctx.typertGateway.getPendingRemoteEvents()).toEqual([]) const agentCtx = ctx.extend({}) const subject = {} const settled = vi.fn() @@ -745,6 +746,10 @@ describe('Gateway forwarded events for non-owner Peers', () => { push({ event: 'fixture/shelf', args: ['s2'], resources }) push({ event: 'fixture/shelf', args: ['s1'], resources }) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'waterfall', event: 'fixture/ask' } }) + const pending = ctx.typertGateway.getPendingRemoteEvents() + expect(pending).toEqual([{ event: 'fixture/ask', agentId: 'agent-1' }]) + expect(pending).not.toBe(ctx.typertGateway.getPendingRemoteEvents()) + expect(pending[0]).not.toBe(ctx.typertGateway.getPendingRemoteEvents()[0]) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s2'] } }) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml index 14239cd17a..a95b536e3c 100644 --- a/packages/api/remote-access/README.i18n.yaml +++ b/packages/api/remote-access/README.i18n.yaml @@ -15,8 +15,8 @@ en: a18ecc232e3c3933 zh: b8e43580403f6d77 /deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: - en: 757a6bdf95e824c6 - zh: e8ae9712ad28c2f3 + en: 3d009c4a86374cbf + zh: ba3c624639ed18b9 /deepseek-ai-dsh-api-remote-access/client-face: en: d4cf8f646fdf2d39 zh: 247f643e991aa806 diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md index f1ddf064d1..f888679c15 100644 --- a/packages/api/remote-access/README.md +++ b/packages/api/remote-access/README.md @@ -39,6 +39,8 @@ A role `{ name, resource, actions }` is a plain value naming an action set over The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default field or resolver declarations in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`. +Policy authors must treat `session/attachment` as access to the entire attachment store: granting that action on any one Session resource lets the caller supply that `sessionId` and read images referenced by other Sessions or by none. Gateway still checks that Session resource, but the handler checks neither Session existence nor image membership. Use separate Host instances with separate `DSH_HOME` directories when readers require separate image stores; see the [image access rule](../../../.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md). + When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot, a throwing `bindings()` leaves the principal no grant and a throwing condition excludes its action. A faulty rule therefore never breaks the shared `$events` source, the snapshot stream, or page rendering. The service logger reports the first failure of each policy after each `invalidate()` and each policy registration or removal; later failures of that policy stay silent until then. A forwarded event is decided as the action `event:` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`. diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md index bcd8e77324..ab073ed7c0 100644 --- a/packages/api/remote-access/README.zh.md +++ b/packages/api/remote-access/README.zh.md @@ -39,6 +39,8 @@ role `{ name, resource, actions }` 是普通值,表示限定一种资源种类 Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段或 resolver 声明解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。 +policy 作者必须把 `session/attachment` 视为整个附件存储的读取权限:在任意一条 Session 资源上授予该 action,就允许调用方使用这个 `sessionId` 读取其他 Session 引用或未被任何 Session 引用的图片。Gateway 仍检查该 Session 资源,但处理函数既不检查 Session 是否存在,也不检查图片关联。需要让不同读者使用不同图片存储时,须使用独立 Host 实例及各自的 `DSH_HOME` 目录;见[图片访问规则](../../../.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.zh.md)。 + `bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时,`bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染。每次 `invalidate()` 以及每次 policy 注册或撤回之后,服务日志对每个 policy 只报告第一次失败;此后该 policy 的失败不再记录,直到下一次上述变化。 转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index cf1badb15b..a9ca9616e5 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 0c8945a47f87b5f7 - zh: b099d66521dca175 + en: 6bbd371bb1907ad1 + zh: 03a1b13862404c14 /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index ae7cff7cb7..708479b2b3 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,9 +33,11 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. -Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/page` and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. +Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/events`, the deprecated `session/page`, `session/observeEvent`, and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections`, `session/observeProjections`, `session/state`, `session/observeState`, and `session/wait` read the Session resource from `request.sessionId`, so each call and subscription names only the Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. + +`events` reads one chronological page from a durable cut and returns its records, `hasMore`, and an inclusive `cursor`; omitting `throughSeq` selects the observed log end, and the compatible `page` method carries the same read with a deprecated entry point. `events`, `observeEvent`, and `follow` take an Address; projection and state operations take a Session ID. Page reads and Follow openings validate the supplied Address. Pure `observeEvent` and `observeProjections` subscriptions neither acquire an Observation nor establish existence: their `ready` frame carries no snapshot cursor. List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. @@ -72,7 +74,7 @@ The Client's initial `follow`, reconnect openings, and `loadOlder()` request at Queue edits replace pending content with non-empty text only. -Attachment authorization reads declared content fields of built-in Session events and completed assistant stream blocks, including flat V4 tool-role messages. Unknown event payloads and unrelated fields cannot authorize attachment reads. +`session.attachment` receives `{ sessionId, attachment: ImageAttachmentRef }` and passes the complete reference to `AttachmentStore.readImage()`, without reading or traversing Session history. The Client supplies the message's reference through `session.readAttachment(ref)`. Connection authentication and Gateway permission checks still apply; `sessionId` selects the Session resource for the endpoint's permission check. After authorization, the handler does not check whether that Session exists and does not return `session/not-found` merely because it is absent. An authorized caller may read any image in the same store, including an image referenced only by another Session or by no Session. The response preserves the supplied reference after verifying its ID, media type, byte length, width, and height against the stored bytes. Optional `name` and `originalDimensions` pass through as caller-supplied display information and are not verified against the file. Missing or invalid objects return a terminal attachment failure. The Client's historical-image cache retains one result and browser URL per Session and attachment ID. The controller composes `ArchivedSessionGate` through `ctx.plugin`: it loads once the Agent registry, Session store, and Workspace registry exist and unwinds with the controller. Its `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one — read from the Session header's lineage fields, never a fork — so a late waking delivery ends its turn as `blocked` without a model request; unarchiving lifts the gate for the whole lineage. The work an archived Session still runs is reported and stopped by its owners through the Workspace registry's archive admission ([seam](../../workspace/workspace/README.md)): the running turn by the [Agent registry](../../core/agent/README.md), owned jobs by the [job registry seam](../../jobs/jobs/README.md), subagent descendants by the [Subagent](../../subagent/subagent/README.md) runtime, reminders by the [Schedule](../../schedule/schedule/README.md) plugin; this controller reports nothing itself. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 0bbbd6ddaf..b64f424d4d 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,9 +33,11 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 -每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/page` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 +每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/events`、已弃用的 `session/page`、`session/observeEvent` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections`、`session/observeProjections`、`session/state`、`session/observeState` 与 `session/wait` 从 `request.sessionId` 读取会话资源,因此每次调用与订阅都只点名它可读的会话。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 + +`events` 从一个持久切点读取一页时间正序的记录,返回记录、`hasMore` 和包含末项的 `cursor`;省略 `throughSeq` 时选取观察到的日志末尾,兼容的 `page` 方法提供同样的读取,但入口已弃用。`events`、`observeEvent` 和 `follow` 接收 Address,投影和状态操作接收 Session ID。分页读取与 Follow 开场会校验传入的 Address。纯 `observeEvent` 和 `observeProjections` 订阅既不获取 Observation,也不确认实体存在:其 `ready` 帧不携带快照游标。 列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 @@ -72,7 +74,7 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 队列编辑仅允许用非空文本替换待处理内容。 -附件授权读取内置 Session 事件声明的内容字段与已完成的 assistant 流块,包括扁平的 V4 tool 角色消息。未知事件载荷与无关字段不能授权附件读取。 +`session.attachment` 接收 `{ sessionId, attachment: ImageAttachmentRef }`,并把完整引用交给 `AttachmentStore.readImage()`,不读取或遍历 Session 历史。Client 通过 `session.readAttachment(ref)` 传入消息中的引用。连接身份认证和 Gateway 权限检查仍然适用;`sessionId` 为该接口的权限检查指定 Session 资源。授权通过后,处理函数不检查该 Session 是否存在,也不会仅因其不存在而返回 `session/not-found`。获准调用的身份可以读取同一存储中的任意图片,包括仅被其他 Session 引用或未被任何 Session 引用的图片。响应在把 ID、媒体类型、字节数、宽度和高度与存储字节校验后,保留传入的引用。可选的 `name` 和 `originalDimensions` 作为调用方提交的显示信息原样返回,不根据文件校验。对象缺失或无效时返回明确的附件失败。Client 的历史图片缓存按 Session 和附件 ID 保留一份结果与浏览器 URL。 本控制器通过 `ctx.plugin` 组合 `ArchivedSessionGate`:在 Agent 注册表、Session store 与 Workspace 注册表就绪后加载,随控制器一起释放。它的 `agent/pre-step` 监听器会拒绝为已归档会话或其子代理子孙——从 Session header 的血缘字段读出,从不包括 fork——提出的步骤,因此迟到的唤醒投递会让该回合以 `blocked` 收口而不发出模型请求;取消归档即为整条血缘解除门禁。已归档会话仍在跑的工作由各自的 owner 通过 Workspace 注册表的归档准入([接缝](../../workspace/workspace/README.zh.md))报告与停止:运行中的回合由 [Agent 注册表](../../core/agent/README.zh.md)负责,所属任务由[任务注册表接缝](../../jobs/jobs/README.zh.md)负责,子代理子孙由 [Subagent](../../subagent/subagent/README.zh.md) runtime 负责,提醒由 [Schedule](../../schedule/schedule/README.zh.md) 插件负责;本控制器自己不报告任何内容。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 0f7b7bb63e..3eadb51df1 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -73,6 +73,7 @@ "dependencies": { "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-deque": "workspace:*", + "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "mime-types": "^3.0.2", "zod": "4.4.3" @@ -131,6 +132,7 @@ "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-attachment-local": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-file-upload": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 2c8f16c4ae..41f4209316 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -7,7 +7,7 @@ * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' @@ -91,12 +91,12 @@ export interface ISession { requestId?: SessionRequestId, ): Promise> /** - * Resolve one durable image referenced by this session. - * @param attachmentId - opaque id found in the folded session log. - * @returns the authenticated reference and decoded bytes. + * Resolve one stored image using this Session's gateway permission scope. + * @param attachment - complete image reference from the folded session log. + * @returns the supplied reference after storage verification, and decoded bytes. */ readAttachment( - attachmentId: AttachmentIdType, + attachment: ImageAttachmentRef, ): Promise> /** * Apply one edit, remove, or Steer action to a still-pending queue occurrence. diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 09494e01ab..59c554b92b 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { InboxState, InboxTarget } from '@deepseek-ai/dsh-agent/types' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { UserMessage } from '@deepseek-ai/dsh-llm/types' @@ -315,16 +315,16 @@ export class Session implements SessionFace { } /** - * Resolve one image referenced by this session into browser-consumable bytes. - * @param attachmentId - opaque id found in the folded session log. - * @returns the authenticated reference and decoded bytes. + * Resolve one stored image into browser-consumable bytes using this Session's gateway permission scope. + * @param attachment - complete image reference from the folded session log. + * @returns the supplied reference after storage verification, and decoded bytes. */ async readAttachment( - attachmentId: AttachmentIdType, + attachment: ImageAttachmentRef, ): Promise> { const result = await this.remote.session.attachment({ sessionId: this.sessionId, - attachmentId, + attachment, }) if (!result.ok) return result const binary = atob(result.value.data) diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index fc2a11a0bb..554eec515e 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -178,6 +178,7 @@ export class SessionEventStream extends RemoteJournalStream< let assistantRevision: number | undefined for await (const frame of this.remote.session.follow({ address: this.address, + activate: true, assistantStream: true, ...this.repairRequest(request), }, signal)) { diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index d7e30384fe..5309872331 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -7,12 +7,12 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { - AttachmentAdmissionPart, ImageAttachmentRef, + AttachmentAdmissionPart, } from '@deepseek-ai/dsh-attachment' import type { FileUploadReceiptId, StagedAttachment } from '@deepseek-ai/dsh-client-file-upload/types' import type {} from '@deepseek-ai/dsh-client-file-upload' import { - ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage, + ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' import { buildForkSeed } from '@deepseek-ai/dsh-session/fork' @@ -26,7 +26,6 @@ import type { Workspace } from '@deepseek-ai/dsh-workspace' import { ApiSessionAgentController, ApiSessionCwdConflict, - ApiSessionNotFound, ApiSessionPresetConflict, ApiSessionSubagentOwnership, apiSessionSubagentOwnershipError, @@ -359,38 +358,13 @@ export class SessionCommandController { } /** - * Read one durable image after proving the Session log references it. - * @param request - Session and attachment identities used for authorization. + * Read one durable image using the supplied reference without consulting Session history. + * @param request - image reference and gateway permission scope; Session existence is not checked. * @returns the durable attachment reference and base64-encoded bytes. */ async attachment(request: SessionAttachmentRequest): Promise { - let ref: ImageAttachmentRef | undefined try { - using observation = await this.ctx.sessionQuery.observeSession(request.sessionId, { projectionMode: 'none' }) - if (observation.source === 'prepared' && observation.header.cwd === undefined) { - throw new ApiSessionNotFound(`session "${request.sessionId}" not found`) - } - ref = referencedImage(observation.events, String(request.attachmentId)) - } catch (error) { - if (error instanceof ApiSessionNotFound - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND')) { - throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }) - } - throw new RemoteError( - 'gateway/internal', - `attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`, - {}, - ) - } - if (ref === undefined) { - throw new RemoteError( - 'session/attachment-invalid', - 'Image is not referenced by this session.', - { reason: 'ATTACHMENT_NOT_REFERENCED' }, - ) - } - try { - const stored = await this.ctx.attachments.readImage(ref) + const stored = await this.ctx.attachments.readImage(request.attachment) return { attachment: stored.ref, data: Buffer.from(stored.data).toString('base64'), @@ -578,80 +552,3 @@ function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId }) } -function imageBlockIn( - content: unknown, - match: (ref: ImageAttachmentRef) => boolean, -): ImageAttachmentRef | undefined { - if (!Array.isArray(content)) return undefined - for (const value of content) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { readonly type?: unknown; readonly attachment?: unknown } - if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { - const ref = block.attachment as ImageAttachmentRef - if (match(ref)) return ref - } - } - return undefined -} - -/** Read only first-party declared content fields; unknown event payloads stay opaque. */ -function imageInEvent( - event: SessionEvent, - match: (ref: ImageAttachmentRef) => boolean, -): ImageAttachmentRef | undefined { - const data = event.data as { - readonly content?: unknown - readonly message?: { readonly content?: unknown } - readonly inserted?: unknown - readonly summary?: unknown - readonly rawOutput?: unknown - } - // First-party event payloads can be present without their producer plugin mounted. - const type: string = event.type - switch (type) { - case 'user/message': - case 'tool/ptc-dispatch': - return imageBlockIn(data.content, match) - case 'system/message': - case 'developer/message': - case 'tool/result': - case 'team/message/queued': - return imageBlockIn(data.message?.content, match) - case 'agent/inbox/spliced': { - const messages = data.inserted - if (!Array.isArray(messages)) return undefined - for (const message of messages as readonly unknown[]) { - if (typeof message !== 'object' || message === null || Array.isArray(message)) continue - const found = imageBlockIn((message as { readonly content?: unknown }).content, match) - if (found !== undefined) return found - } - return undefined - } - case 'compaction/summary': - return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match) - case 'assistant/message': { - const found = imageBlockIn(data.message?.content, match) - if (found !== undefined) return found - break - } - case 'assistant/attempt': break - default: return undefined - } - const assistant = event as SessionEvent<'assistant/message' | 'assistant/attempt'> - for (const chunk of assistantStreamChunks(assistant.data.stream, 'block-end')) { - const found = imageBlockIn([chunk.block], match) - if (found !== undefined) return found - } - return undefined -} - -function referencedImage( - events: readonly SessionEvent[], - attachmentId: string, -): ImageAttachmentRef | undefined { - for (const event of events) { - const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId) - if (found !== undefined) return found - } - return undefined -} diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index 07fbbfe6be..91cc3508da 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -1,12 +1,8 @@ -/** Cold Session history pagination and live-event source. */ +/** Shared Session history pagination, address validation and wire conversion. */ -import type { Context } from '@deepseek-ai/cordis' -import { Deque } from '@deepseek-ai/dsh-deque' -import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent' import { isAppendSurfaceEvent, SessionLogOffset, - SessionSeq, } from '@deepseek-ai/dsh-session' import type { SessionEvent, @@ -15,309 +11,26 @@ import type { SessionLogOffset as SessionLogOffsetType, SessionSeqCursor, } from '@deepseek-ai/dsh-session' -import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-subagent' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { SessionAddress, - SessionAssistantStreamFrame, SessionEventEntry, - SessionFollowRequest, - SessionFollowFrame, SessionHistoryRecord, - SessionPage, SessionPageRequest, - SessionProjectionBaseline, - SessionProjectionValues, - SessionWireHeader, SessionWireEvent, } from './types.ts' -import { SessionAssistantStreamAccumulator } from './assistant-stream.ts' -const DEFAULT_MAX_MESSAGES = 50 +/** Message budget applied when a history request omits `maxMessages`. */ +export const DEFAULT_MAX_MESSAGES = 50 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) -/** Implements cold-safe history operations delegated by the Session Controller. */ -export class SessionHistoryController { - private readonly closeFollowers = new Set<() => void>() - private readonly assistantStreams = new Map() - - /** - * @param ctx - Host context carrying Session query and projection services. - * @param promote - starts ordinary Session activation after snapshot delivery. - */ - constructor( - private readonly ctx: Context, - private readonly promote: (observation: SessionObservation) => void, - ) { - ctx.on('agent/assistant-stream', ({ agent, frame }) => { - let stream = this.assistantStreams.get(agent.session.id) - if (stream === undefined) { - stream = new SessionAssistantStreamAccumulator() - this.assistantStreams.set(agent.session.id, stream) - } - stream.accept(frame, cursorBeforeNext(agent.session.seq)) - }, { global: true }) - ctx.on('agent/disposed', ({ agent }) => { - this.assistantStreams.delete(agent.session.id) - }, { global: true }) - ctx.effect(() => () => { - for (const close of this.closeFollowers) close() - this.closeFollowers.clear() - }, 'session-controller.history') - } - - /** - * Read one message-aligned history page without activating an Agent. - * @param request - durable address and backwards-page cursor. - * @param signal - caller cancellation for persistence reads. - * @returns a contiguous event page. - */ - async page(request: SessionPageRequest, signal: AbortSignal): Promise { - validatePageRequest(request) - const throughSeq: SessionSeqCursor = request.throughSeq === -1 - ? -1 - : SessionSeq(request.throughSeq) - const beforeSeq = request.beforeSeq === undefined - ? undefined - : SessionLogOffset(request.beforeSeq) - using source = await this.sourceFor(request.address, signal, false) - signal.throwIfAborted() - const sourceLog = source.events - const sourceCursor: SessionSeqCursor = sourceLog.at(-1)?.seq ?? -1 - if (throughSeq > sourceCursor) { - throw new RemoteError( - 'gateway/bad-request', - `session page through seq ${String(throughSeq)} is past cursor ${String(sourceCursor)}`, - {}, - ) - } - /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */ - if (throughSeq >= 0 && sourceLog[throughSeq]?.seq !== throughSeq) { - throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(throughSeq)}`, {}) - } - const page = paginate( - sourceLog, - beforeSeq, - request.maxMessages ?? DEFAULT_MAX_MESSAGES, - throughSeq, - request.turnWindow, - ) - const records = pageRecords(page.events) - return { - records, - hasMore: page.hasMore, - } - } - - /** - * Follow events appended after an initial cursor on one durable address. - * @param request - durable address and last committed sequence already held by the caller. - * @param signal - stream cancellation owned by the Remote carrier. - * @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames. - */ - async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { - validateHistoryWindow(request) - const { address } = request - const target = addressId(address) - const buffered = new Deque< - | { readonly type: 'event'; readonly event: SessionEvent } - | { - readonly type: 'assistant-stream' - readonly frame: SessionAssistantStreamFrame - readonly ordinal: number - } - >() - let snapshotCursor: SessionSeqCursor | undefined - let assistantStreamOrdinal = 0 - let wake: (() => void) | undefined - const notify = (): void => { - const resume = wake - wake = undefined - resume?.() - } - const follower = { closed: false } - const close = (): void => { - follower.closed = true - notify() - } - this.closeFollowers.add(close) - const disposeEvent = this.ctx.on('session/event', (session, event) => { - if (session.id !== target) return - buffered.pushBack({ type: 'event', event }) - notify() - }, { global: true }) - const disposeCreated = this.ctx.on('session/created', (session) => { - if (session.id !== target) return - // Constructor seed events have no session/event notification. Normally - // only the end-seed suffix is new; if persistence advanced after the - // opening observation, replay everything beyond that snapshot cursor. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. - const suffix = session.snapshotEvents(snapshotCursor === undefined - ? session.firstLiveSeq - : SessionLogOffset(snapshotCursor + 1)) - for (let index = suffix.length - 1; index >= 0; index -= 1) { - buffered.pushFront({ type: 'event', event: suffix[index] as SessionEvent }) - } - notify() - }, { global: true }) - const disposeAssistantStream = request.assistantStream !== true - ? undefined - : this.ctx.on('agent/assistant-stream', ({ agent, frame }) => { - if (agent.session.id !== target) return - buffered.pushBack({ - type: 'assistant-stream', - frame: wireAssistantStreamFrame(frame, cursorBeforeNext(agent.session.seq)), - ordinal: ++assistantStreamOrdinal, - }) - notify() - }, { global: true }) - const onAbort = (): void => { notify() } - signal.addEventListener('abort', onAbort, { once: true }) - try { - using source = await this.sourceFor(address, signal, true) - const events = source.events - signal.throwIfAborted() - const cursor = source.cursor - snapshotCursor = cursor - const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES, cursor, request.turnWindow) - const assistantStream = request.assistantStream === true - ? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 } - : undefined - // The accumulator snapshot and this watermark are synchronous. Frames - // through the cut are represented or superseded by that baseline, - // including larger revisions from a retired Agent; later revision - // resets reach Client continuity validation. - const assistantStreamOrdinalCut = assistantStreamOrdinal - yield { - type: 'snapshot', - header: wireHeader(source.header), - cursor, - records: pageRecords(page.events), - hasMore: page.hasMore, - projections: source.projections === undefined - ? { asOfSeq: cursor, values: {} } - : projectionBlock(source.projections), - ...assistantStream === undefined ? {} : { assistantStream }, - } - if (address.kind === 'session' && source.source === 'prepared') { - const promotion = source.retain() - try { - this.promote(promotion) - } catch (error: unknown) { - promotion[Symbol.dispose]() - throw error - } - } - let nextOffset = SessionLogOffset(cursor + 1) - while (!follower.closed && !signal.aborted) { - const item = buffered.popFront() - if (item === undefined) { - await new Promise((resolve) => { wake = resolve }) - continue - } - if (item.type === 'assistant-stream') { - if (item.ordinal > assistantStreamOrdinalCut) { - yield { type: 'assistant-stream', frame: item.frame } - } - continue - } - const expectedSeq = SessionSeq(nextOffset) - if (item.event.seq < expectedSeq) continue - if (item.event.seq !== expectedSeq) { - throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(expectedSeq)}`, {}) - } - nextOffset = SessionLogOffset(nextOffset + 1) - yield entryFor(item.event) - } - } finally { - this.closeFollowers.delete(close) - signal.removeEventListener('abort', onAbort) - disposeCreated() - disposeEvent() - disposeAssistantStream?.() - } - } - - private async sourceFor( - address: SessionAddress, - signal: AbortSignal, - withProjections: boolean, - ): Promise { - const sessionId = addressId(address) - try { - const observation = await this.ctx.sessionQuery.observeSession(sessionId, { - signal, - projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none', - }) - if (observation.header.cwd === undefined) { - observation[Symbol.dispose]() - rejectNotFound(address) - } - try { - validateAddress( - address, - observation.header, - observation.inheritedEventCount, - observation.projections, - ) - } catch (error: unknown) { - observation[Symbol.dispose]() - throw error - } - return observation - } catch (error: unknown) { - if (error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') rejectNotFound(address) - throw error - } - } - -} - -function cursorBeforeNext(nextSeq: SessionLogOffsetType): SessionSeqCursor { - return nextSeq === 0 ? -1 : SessionSeq(nextSeq - 1) -} - -function wireAssistantStreamFrame( - frame: AssistantStreamFrame, - durableCursor: SessionSeqCursor, -): SessionAssistantStreamFrame { - if (frame.type === 'start') return { ...frame, startedAfterSeq: durableCursor } - if (frame.type === 'end') return frame - return { - ...frame, - chunk: frame.chunk as JsonValue, - } -} - -function projectionBlock( - snapshot: NonNullable, -): SessionProjectionBaseline { - return { - asOfSeq: snapshot.asOfSeq, - // Projection definitions validate whole JSON values before snapshot publication. - values: snapshot.values as SessionProjectionValues, - } -} - -function validatePageRequest(request: SessionPageRequest): void { - if (!Number.isSafeInteger(request.throughSeq) - || request.throughSeq < -1 - || Object.is(request.throughSeq, -0)) { - throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) - } - if (request.beforeSeq !== undefined - && (!Number.isSafeInteger(request.beforeSeq) - || request.beforeSeq < 0 - || Object.is(request.beforeSeq, -0))) { - throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {}) - } - validateHistoryWindow(request) -} - -function validateHistoryWindow(request: Pick): void { +/** + * Reject an invalid message budget or turn window. + * @param request - history window fields. + */ +export function validateHistoryWindow(request: Pick): void { if (request.maxMessages !== undefined && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) @@ -334,11 +47,23 @@ function validateHistoryWindow(request: Pick 0 } } -/** Translate current logical Session metadata to the browser wire. */ -function wireHeader(header: SessionHeader): SessionWireHeader { - return { ...header } -} - -function entryFor(event: SessionEvent): SessionEventEntry { +/** + * Convert a committed event to its wire entry. + * @param event - committed Session event. + * @returns the wire event entry. + */ +export function entryFor(event: SessionEvent): SessionEventEntry { return { type: 'event', // Session.append validates and freezes event data as JSON before publication. @@ -439,7 +179,11 @@ function entryFor(event: SessionEvent): SessionEventEntry { } } -/** Encode one bounded logical page without changing its pagination cut. */ -function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] { +/** + * Encode one bounded logical page without changing its pagination cut. + * @param events - page events. + * @returns the wire records. + */ +export function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] { return events.map(entryFor) } diff --git a/packages/api/session-controller/src/history/assistant-stream.ts b/packages/api/session-controller/src/history/assistant-stream.ts new file mode 100644 index 0000000000..b42a0aba29 --- /dev/null +++ b/packages/api/session-controller/src/history/assistant-stream.ts @@ -0,0 +1,102 @@ +/** Assistant stream baselines and per-connection subscriptions. */ + +import type { Context } from '@deepseek-ai/cordis' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { SessionAssistantStreamAccumulator } from '../assistant-stream.ts' +import type { SessionAssistantStreamBaseline, SessionAssistantStreamFrame } from '../types.ts' +import { ListenerBuffer } from './listener.ts' + +/** One accumulator per live Session, shared by every subscription bound to it. */ +export class SessionAssistantStreamsListener { + private readonly streams = new Map() + + /** @param ctx - service context owning the accumulator listeners. */ + constructor(private readonly ctx: Context) { + ctx.on('agent/assistant-stream', ({ agent, frame }) => { + let stream = this.streams.get(agent.session.id) + if (stream === undefined) { + stream = new SessionAssistantStreamAccumulator() + this.streams.set(agent.session.id, stream) + } + stream.accept(frame, agent.session.seq === 0 ? -1 : SessionSeq(agent.session.seq - 1)) + }, { global: true }) + ctx.on('agent/disposed', ({ agent }) => { this.streams.delete(agent.session.id) }, { global: true }) + ctx.effect(() => () => { this.streams.clear() }, 'session-controller.assistant-streams') + } + + /** + * Bind one connection to a Session assistant stream. + * @param sessionId - Session to subscribe. + * @param signal - cancellation owned by the caller. + * @param queue - caller-owned raw notification buffer. + * @returns a listener the caller disposes. + */ + bind(sessionId: SessionId, signal: AbortSignal, + queue: Pick, 'createInput' | 'capture'>): AssistantStreamListener { + return new AssistantStreamListener(this.ctx, sessionId, signal, () => { + return this.streams.get(sessionId)?.snapshot() ?? { revision: 0 } + }, queue) + } +} + +type AssistantEntry = { type: 'assistant-stream'; frame: SessionAssistantStreamFrame } + +/** An assistant notification and its connection-local arrival position. */ +interface AssistantItem { + readonly type: 'assistant-stream' + readonly session: Session + readonly frame: SessionAssistantStreamFrame + readonly ordinal: number +} + +/** Writes assistant notifications into the connection's shared queue. */ +class AssistantStreamListener implements Disposable { + private readonly disposes: (() => void)[] = [] + private ordinal = 0 + private cut = 0 + + constructor( + ctx: Context, + sessionId: SessionId, + signal: AbortSignal, + private _snapshot: () => SessionAssistantStreamBaseline, + output: Pick, 'createInput' | 'capture'>) { + signal.throwIfAborted() + const input = output.createInput(item => this.transform(item)) + try { + this.disposes.push(ctx.on('agent/assistant-stream', ({ agent, frame }) => { + if (agent.session.id !== sessionId) return + output.capture(() => { + const wire: SessionAssistantStreamFrame = frame.type === 'start' + ? { ...frame, startedAfterSeq: agent.session.seq === 0 ? -1 : SessionSeq(agent.session.seq - 1) } + : frame.type === 'chunk' ? { ...frame, chunk: frame.chunk as JsonValue } : frame + input.append({ type: 'assistant-stream', session: agent.session, frame: wire, ordinal: ++this.ordinal }) + }) + }, { global: true })) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** @returns the baseline and local arrival cut captured synchronously together. */ + snapshot(): { baseline: SessionAssistantStreamBaseline; ordinal: number } { + return { baseline: this._snapshot(), ordinal: this.ordinal } + } + + /** @param ordinal - local snapshot cut set before queue consumption. */ + setOrdinalCut(ordinal: number): void { + this.cut = ordinal + } + + private *transform(item: AssistantItem): Iterable { + if (this.disposes.length === 0 || item.ordinal <= this.cut) return + yield { type: 'assistant-stream', frame: item.frame } + } + + [Symbol.dispose](): void { + for (const dispose of this.disposes.splice(0).reverse()) dispose() + } +} diff --git a/packages/api/session-controller/src/history/controller.ts b/packages/api/session-controller/src/history/controller.ts new file mode 100644 index 0000000000..d090f0a3f7 --- /dev/null +++ b/packages/api/session-controller/src/history/controller.ts @@ -0,0 +1,256 @@ +/** Independent Session snapshots and passive event subscriptions. */ + +import type { Context } from '@deepseek-ai/cordis' +import { SessionLogOffset, SessionSeq, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { + DEFAULT_MAX_MESSAGES, paginate, pageRecords, entryFor, addressId, + validateHistoryWindow, validateAddress, rejectNotFound, +} from '../history.ts' +import { EventListener, ProjectionListener } from './listener.ts' +import { SessionAssistantStreamsListener } from './assistant-stream.ts' +import type { + SessionEventFrame, SessionEventsPage, SessionPageRequest, + SessionObserveEventRequest, SessionProjectionsRequest, SessionProjectionFrame, + SessionAddress, SessionProjectionBaseline, SessionProjectionValues, SessionProjectionsValue, SessionWaitOutput, SessionHistoryRecord, +} from '../types.ts' + + +/** Session snapshots do not share lifetime with passive subscriptions. */ +export class HistoryController { + + /** Reconnect state for this controller's assistant subscriptions. */ + readonly streamsListener: SessionAssistantStreamsListener + + /** @param ctx - Host context carrying Session reads and projections. */ + constructor(private readonly ctx: Context) { + this.streamsListener = new SessionAssistantStreamsListener(this.ctx) + } + + /** + * Read a passive page of Session events at an exact cut. + * @param request - Session identity and optional cut and page window. + * @param signal - read cancellation. + * @returns a page at the supplied cut, or the observed log end when omitted. + */ + async events(request: SessionPageRequest, signal: AbortSignal): Promise { + const spec = resolveHistoryRequest(request) + try { + return await this.usingObservation({ sessionId: addressId(request.address), projection: request.address.kind === 'subagent' }, signal, (observation) => { + validateObservationAddress(request.address, observation) + return history(observation, spec) + }) + } catch (error: unknown) { + if (remoteErrorOf(error)?.code === 'session/not-found') rejectNotFound(request.address) + throw error + } + } + + /** + * Subscribe to Session events without reading or activating the Session. + * @param request - Session address to subscribe. + * @param signal - stream cancellation. + * @returns subscription readiness followed by event frames; readiness does not establish existence. + */ + async *observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable { + using listener = new EventListener(this.ctx, addressId(request.address), signal) + yield { type: 'ready' } + yield* listener.events() + } + + /** + * Read exact projections or migration cache hints without activating the Session. + * @param request - Session identity whose exact projection baseline is required. + * @param signal - read cancellation. + * @returns exact sequenced values, migration cache hints, or null when absent. + */ + async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { + const { sessionId } = request + if (sessionId.length === 0) { + throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {}) + } + try { + signal.throwIfAborted() + const live = this.ctx.sessions.get(sessionId) + if (live !== undefined) { + const snapshot = this.ctx.sessionProjections.snapshot(live) + return { kind: 'sequenced', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } + } else { + const stored = await this.ctx.get('sessionPersistence')?.stat(sessionId, { signal }) + signal.throwIfAborted() + if (stored?.formatStatus === 'migration-required') { + const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(stored.header) + return { kind: 'migration-required', values: { ...cached?.values } as SessionProjectionValues } + } + return await this.usingObservation({ sessionId: request.sessionId, projection: true }, signal, observation => ({ + kind: 'sequenced' as const, + ...projections(observation), + })) + } + } catch (error: unknown) { + const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined + if (code === 'SESSION_QUERY_SESSION_NOT_FOUND' || code === 'session/not-found') { + return null + } + if (signal.aborted || code === 'SESSION_QUERY_ABORTED') { + throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error }) + } + if (remoteErrorOf(error) !== undefined) throw error + throw new RemoteError('gateway/internal', 'Session projection read failed', {}, { cause: error }) + } + } + + /** + * Subscribe to sparse projection replacements without reading a baseline. + * @param request - Session identity to subscribe without reading a baseline. + * @param signal - stream cancellation. + * @returns readiness followed by sparse per-key replacement values. + */ + async *observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable { + using listener = new ProjectionListener(this.ctx, request.sessionId, signal) + yield { type: 'ready' } + yield* listener.changes() + } + + /** + * Read bounded committed assistant messages through a stopping cursor. + * @param sessionId - Session whose committed assistant messages are requested. + * @param afterSeq - exclusive lower bound. + * @param throughSeq - inclusive stopping-state cursor. + * @param maxMessages - positive assistant record limit. + * @param signal - caller cancellation, independent of a completed wait deadline. + * @returns newest-first assistant messages and whether further matching records were omitted. + */ + async assistantOutput( + sessionId: SessionId, afterSeq: number, throughSeq: number, maxMessages: number, signal: AbortSignal, + ): Promise { + return this.usingObservation({ sessionId, projection: false }, signal, (observation) => { + if (throughSeq > observation.cursor) { + throw new RemoteError('gateway/internal', 'Session history no longer covers the wait cursor', {}) + } + const records: SessionHistoryRecord[] = [] + const events = observation.events + for (let index = throughSeq; index > afterSeq; index--) { + const event = events[index] as SessionEvent + if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) continue + if (records.length === maxMessages) return { records, truncated: true } + records.push(entryFor(event)) + } + return { records, truncated: false } + }) + } + + /** + * Acquire a read without activating a Session; projections are opt-in. + * @param request - Session identity and whether to compute projections. + * @param signal - read cancellation. + * @param callback - omitted to transfer lease ownership to the caller. + * @returns a lease the caller must dispose. + */ + usingObservation( + request: { readonly sessionId: SessionId; readonly projection: boolean }, signal: AbortSignal, callback?: undefined, + ): Promise + /** + * Keep the borrowed lease alive until the callback settles, then dispose it. + * @param request - Session identity and whether to compute projections. + * @param signal - read cancellation. + * @param callback - synchronous or asynchronous computation; retain a separate lease if it must escape. + * @returns the callback result; read and callback failures propagate. + */ + usingObservation( + request: { readonly sessionId: SessionId; readonly projection: boolean }, signal: AbortSignal, + callback: (observation: SessionObservation) => T, + ): Promise> + async usingObservation( + { sessionId, projection }: { readonly sessionId: SessionId; readonly projection: boolean }, signal: AbortSignal, + callback?: (observation: SessionObservation) => T, + ): Promise> { + let observation: SessionObservation + try { + observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal, projectionMode: projection ? 'all' : 'none' }) + } catch (error: unknown) { + const code = (error as { readonly code?: unknown } | null | undefined)?.code + if (code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + throw new RemoteError('session/not-found', `session "${sessionId}" not found`, { sessionId }) + } + if (code === 'SESSION_QUERY_ABORTED') { + // Keep the query's own message: cancellation detail was visible before this mapping. + throw new RemoteError('gateway/cancelled', + error instanceof Error ? error.message : 'Session read was cancelled', {}, { cause: error }) + } + throw error + } + try { + signal.throwIfAborted() + } catch (error: unknown) { + observation[Symbol.dispose]() + throw error + } + if (callback === undefined) return observation + using ownedObservation = observation + return await callback(ownedObservation) + } +} + +/** + * Validate an Address against the exact read used by a Controller. + * @param address - caller-supplied Session address. + * @param observation - borrowed read; subagent addresses require projections. + */ +export function validateObservationAddress(address: SessionAddress, observation: SessionObservation): void { + if (observation.header.cwd === undefined) rejectNotFound(address) + validateAddress(address, observation.header, observation.inheritedEventCount, observation.projections) +} + +/** + * Convert a borrowed observation to one page without acquiring or retaining it. + * @param observation - exact read cut owned by the caller. + * @param request - validated page window and optional inclusive cut. + * @returns a chronological page at the requested cut. + */ +export function history(observation: SessionObservation, request: Omit & { readonly maxMessages: number }): SessionEventsPage { + const cursor = request.throughSeq === undefined ? observation.cursor + : request.throughSeq === -1 ? -1 : SessionSeq(request.throughSeq) + if (cursor > observation.cursor) { + throw new RemoteError('gateway/bad-request', `session history cut ${String(cursor)} exceeds ${String(observation.cursor)}`, {}) + } + const page = paginate( + observation.events, + request.beforeSeq === undefined ? undefined : SessionLogOffset(request.beforeSeq), + request.maxMessages, + cursor, + request.turnWindow, + ) + return { cursor, records: pageRecords(page.events), hasMore: page.hasMore } +} + +/** + * Convert the projection baseline without acquiring or retaining its observation. + * @param observation - projected read cut owned by the caller. + * @returns the exact baseline; missing projection support fails explicitly. + */ +export function projections(observation: SessionObservation): SessionProjectionBaseline { + const snapshot = observation.projections + if (snapshot === undefined) throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {}) + return { asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } +} + +/** + * Validate and resolve the history page window. + * @param request - external history window. + * @returns the validated request with an explicit message budget. + */ +export function resolveHistoryRequest(request: SessionPageRequest): SessionPageRequest & { readonly maxMessages: number } { + if (request.throughSeq !== undefined + && (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1 || Object.is(request.throughSeq, -0))) { + throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) + } + if (request.beforeSeq !== undefined + && (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0 || Object.is(request.beforeSeq, -0))) { + throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {}) + } + validateHistoryWindow(request) + return { ...request, maxMessages: request.maxMessages ?? DEFAULT_MAX_MESSAGES } +} diff --git a/packages/api/session-controller/src/history/listener.ts b/packages/api/session-controller/src/history/listener.ts new file mode 100644 index 0000000000..a272081620 --- /dev/null +++ b/packages/api/session-controller/src/history/listener.ts @@ -0,0 +1,303 @@ +/** Passive per-Session streams; subscriptions never acquire Session observations. */ + +import type { Context } from '@deepseek-ai/cordis' +import { Deque } from '@deepseek-ai/dsh-deque' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { entryFor } from '../history.ts' +import type { SessionProjectionChange, SessionEventEntry } from '../types.ts' + +/** Raw history notifications buffered before an opening cut is known. */ +export type EventItem = + | { readonly type: 'event'; readonly session: Session; readonly event: SessionEvent } + | { readonly type: 'created'; readonly session: Session; readonly end: number } + +/** A single subscription buffers before either consumption mode starts. */ +export class EventListener implements Disposable { + private readonly buffer: ListenerBuffer | undefined + private readonly disposes: (() => void)[] = [] + private firstReceivedSeq: number | undefined + private nextExpectedSeq: number | undefined + + /** + * @param ctx - context owning the event registrations. + * @param sessionId - Session whose notifications are emitted. + * @param signal - cancellation that wakes consumers. + * @param queue - optional caller-owned buffer; only an internally created buffer is disposed here. + */ + constructor(ctx: Context, sessionId: SessionId, signal: AbortSignal, + queue?: Pick, 'createInput' | 'capture'>) { + signal.throwIfAborted() + const buffer = queue ?? new ListenerBuffer(ctx, signal) + this.buffer = queue === undefined ? buffer as ListenerBuffer : undefined + const input = buffer.createInput(item => this.transform(item)) + const target = sessionId + try { + this.disposes.push(ctx.on('session/event', (session, event) => { + if (session.id !== target) return + buffer.capture(() => { + this.recordFirst(event.seq) + input.append({ type: 'event', session, event }) + }) + }, { global: true })) + this.disposes.push(ctx.on('session/created', (session) => { + if (session.id !== target) return + buffer.capture(() => { + if (session.firstLiveSeq < session.seq) this.recordFirst(session.firstLiveSeq) + input.prepend({ type: 'created', session, end: session.seq }) + }) + }, { global: true })) + this.buffer?.own(() => { this[Symbol.dispose]() }) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** Consume notifications without reading history. + * @returns event frames from the first received event, without loading a baseline. */ + events(): AsyncIterable { + return this.iterate() + } + + /** Set the inclusive opening cut for buffered history notifications. + * @param cursor - snapshot cut set before consuming the queue. */ + setCursor(cursor: number): void { + this.nextExpectedSeq = cursor + 1 + } + + private *transform(item: EventItem): Iterable { + if (this.disposes.length === 0) return + this.nextExpectedSeq ??= this.firstReceivedSeq + const result = eventItem(item, this.nextExpectedSeq) + this.nextExpectedSeq = result.next + for (const event of result.events) { + if (this.disposes.length === 0) return + yield entryFor(event) + } + } + + private async *iterate(): AsyncIterable { + if (this.buffer === undefined) throw new Error('Consume the externally owned history buffer directly') + try { + yield* this.buffer.items() + } finally { + this[Symbol.dispose]() + } + } + + private recordFirst(seq: number): void { + this.firstReceivedSeq = this.firstReceivedSeq === undefined ? seq : Math.min(this.firstReceivedSeq, seq) + } + + [Symbol.dispose](): void { + for (const dispose of this.disposes.splice(0).reverse()) dispose() + this.buffer?.[Symbol.dispose]() + } +} + +/** + * Select the uncovered events represented by one notification. + * @param item - one buffered notification. + * @param next - next required seq, or undefined before the first event. + * @returns uncovered events and the next required seq without acquiring an observation. + */ +export function eventItem(item: EventItem, next?: number): { + events: readonly SessionEvent[] + next: number | undefined +} { + if (item.type === 'event') { + if (next !== undefined && item.event.seq < next) return { events: [], next } + if (next !== undefined && item.event.seq !== next) { + throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(next)}`, {}) + } + return { events: [item.event], next: item.event.seq + 1 } + } + const start = next ?? item.session.firstLiveSeq + if (start >= item.end) return { events: [], next } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session-created history adaptation. + const events = item.session.snapshotEvents(SessionLogOffset(start), SessionLogOffset(item.end)) + for (const event of events) { + if (next !== undefined && event.seq !== next) { + throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(next)}`, {}) + } + next = event.seq + 1 + } + return { events, next } +} + +/** Sparse projection changes retain every key, including keys sharing a seq. */ +export class ProjectionListener implements Disposable { + private readonly buffer: ListenerBuffer<{ session: Session; change: SessionProjectionChange }> + + /** + * @param ctx - context owning projection notifications. + * @param sessionId - Session whose notifications are emitted. + * @param signal - cancellation that removes registrations and wakes consumers. + */ + constructor(ctx: Context, sessionId: SessionId, signal: AbortSignal) { + this.buffer = new ListenerBuffer(ctx, signal) + const input = this.buffer.createInput<{ session: Session; change: SessionProjectionChange }>(item => [item]) + const target = sessionId + try { + this.buffer.own(ctx.sessionProjections.onChanged((session, key, value, seq) => { + if (session.id !== target) return + this.buffer.capture(() => { + input.append({ + session, change: { type: 'projection', key, value: value as JsonValue, seq } satisfies SessionProjectionChange, + }) + }) + })) + } catch (error: unknown) { + this.buffer[Symbol.dispose]() + throw error + } + } + + /** Consume sparse per-key projection replacements. + * @returns validated replacement frames; projection seqs need not be contiguous. */ + async *changes(): AsyncIterable { + try { + for await (const { change } of this.buffer.items()) { + yield change + } + } finally { + this[Symbol.dispose]() + } + } + + [Symbol.dispose](): void { + this.buffer[Symbol.dispose]() + } +} + +/** Owns buffering, callback failure and registration cleanup for one consumer. */ +export class ListenerBuffer implements Disposable { + private readonly values = new Deque() + private readonly transformIds = new Deque() + private readonly transforms: ((value: unknown) => Iterable)[] = [] + private readonly cleanup: (() => void)[] = [] + private wake: (() => void) | undefined + private closed = false + private failure: { readonly error: unknown } | undefined + private consumed = false + + constructor(ctx: Context, signal: AbortSignal) { + signal.throwIfAborted() + const close = (): void => { this[Symbol.dispose]() } + signal.addEventListener('abort', close, { once: true }) + this.own(() => { signal.removeEventListener('abort', close) }) + try { + const dispose = ctx.effect(() => close, 'session-controller.listener-buffer') + this.own(() => { void dispose() }) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** + * Register cleanup, or run it immediately when already closed. + * @param dispose - synchronous cleanup. + */ + own(dispose: () => void): void { + if (this.closed) dispose() + else this.cleanup.push(dispose) + } + + /** + * Contain producer failure and wake the consumer with that failure. + * @param action - synchronous producer operation. + */ + capture(action: () => void): void { + if (this.closed) return + try { + action() + } catch (error: unknown) { + this.failure = { error } + this[Symbol.dispose]() + } + } + + /** + * Register a dequeue transform and obtain its write-only input. + * @param transform - conversion bound once and evaluated when each input is dequeued. + * @returns a write-only input sharing this buffer's storage and lifetime. + * @throws when this buffer is already closed. + */ + createInput(transform: (item: Input) => Iterable): { append(item: Input): void; prepend(item: Input): void } { + if (this.closed) throw new Error('Cannot register a transform on a closed listener buffer') + const transformId = this.transforms.length + // Only this typed input can enqueue values with its private transform id. + this.transforms.push(value => transform(value as Input)) + return { + append: (item) => { + if (this.closed) return + this.values.pushBack(item) + this.transformIds.pushBack(transformId) + this.notify() + }, + prepend: (item) => { + if (this.closed) return + this.values.pushFront(item) + this.transformIds.pushFront(transformId) + this.notify() + }, + } + } + + /** + * Consume queued values once, releasing the buffer when iteration ends. + * @returns transformed output values. + */ + async *items(): AsyncIterable { + if (this.consumed) throw new RemoteError('gateway/internal', 'Session listener already has a consumer', {}) + this.consumed = true + try { + for (;;) { + if (this.failure !== undefined) throw this.failure.error + if (this.closed) return + if (this.transformIds.size === 0) { + await new Promise((resolve) => { this.wake = resolve }) + continue + } + const transformId = this.transformIds.popFront() as number + const value = this.values.popFront() + for (const item of (this.transforms[transformId] as (value: unknown) => Iterable)(value)) { + this.throwIfFailed() + if (this.isClosed()) return + yield item + } + } + } finally { + this[Symbol.dispose]() + } + } + + private throwIfFailed(): void { + if (this.failure !== undefined) throw this.failure.error + } + + /** @returns whether disposal ended this buffer while a transform was yielding. */ + private isClosed(): boolean { + return this.closed + } + + private notify(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } + + [Symbol.dispose](): void { + if (this.closed) return + this.closed = true + this.values.clear() + this.transformIds.clear() + this.transforms.length = 0 + this.notify() + for (const dispose of this.cleanup.splice(0).reverse()) dispose() + } +} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 125bd1f3e3..94d08c77e3 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -11,7 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection-cache' -import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, RemoteError, TypertRemoteService, principalOf, type TypertResourceResolveInput } from '@deepseek-ai/dsh-typert-protocol' import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' import { assertNever } from '@deepseek-ai/dsh-util-values' @@ -22,7 +22,9 @@ import { } from './agent.ts' import { SessionCommandController, hasPromptContent } from './commands.ts' import { SessionControlController } from './control.ts' -import { SessionHistoryController } from './history.ts' +import { HistoryController } from './history/controller.ts' +import { StateController } from './state/controller.ts' +import { WebController } from './web/controller.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList } from './list.ts' import { buildModelCatalog, hasProviderApiKey } from './catalog.ts' @@ -48,8 +50,8 @@ import type { SessionListValue, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, - SessionPage, - SessionPageRequest, + SessionEventsPage, SessionPageRequest, SessionEventFrame, SessionObserveEventRequest, + SessionProjectionFrame, SessionStateRequest, SessionState, SessionWaitRequest, SessionWaitValue, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, @@ -60,7 +62,6 @@ import type { SessionSelectModelValue, SessionProjectionsRequest, SessionProjectionsValue, - SessionProjectionValues, SessionUpdateQueueRequest, SessionUpdateQueueValue, } from './types.ts' @@ -144,6 +145,7 @@ export class SessionController extends TypertRemoteService { 'sessionProjections', 'sessionQuery', 'typert', + 'typertGateway', 'workspaceRegistry', ] @@ -152,10 +154,13 @@ export class SessionController extends TypertRemoteService { listWorkSliceMs: z.natural().min(1).default(16), }) + private historyController!: HistoryController + private stateController!: StateController + private webController!: WebController + private agents!: ApiSessionAgentController private commands!: SessionCommandController private controlState!: SessionControlController - private history!: SessionHistoryController private listState!: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly fileApplications: typeof nativeFileApplications @@ -198,7 +203,11 @@ export class SessionController extends TypertRemoteService { ctx.effect(() => async () => { await Promise.allSettled([...this.promotions]) }, 'session-controller.promotions') - this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) + // Assistant-stream observation must be live before the list read yields: an Agent's + // opening frame arriving during that await would otherwise have no listener. + this.historyController = new HistoryController(ctx) + this.stateController = new StateController(ctx, this.historyController) + this.webController = new WebController(ctx, this.historyController, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx, this.listWorkSliceMs) await this.listState.initialize() ctx.plugin(SessionFileReferences) @@ -234,6 +243,10 @@ export class SessionController extends TypertRemoteService { }) } + /** + * Request background activation through the existing Session Agent controller. + * @param observation - owned lease disposed by the background task; callers retain their own lease separately. + */ private promote(observation: SessionObservation): void { const sessionId = observation.header.id const task = (async () => { @@ -476,8 +489,8 @@ export class SessionController extends TypertRemoteService { } /** - * Read one image proven reachable from the addressed Session log. - * @param request - Session and attachment identities used for authorization. + * Read one image from the attachment store after gateway authorization. + * @param request - image reference and gateway permission scope; Session existence is not checked. * @returns the durable attachment reference and base64-encoded bytes. */ @Remote('attachment') @@ -507,13 +520,80 @@ export class SessionController extends TypertRemoteService { /** * Read one cold-safe, message-aligned Session history page. - * @param request - durable address, backward cursor, and page budget. + * @deprecated Use `events`; this entry point remains for existing Web callers. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page. + * @returns one chronological page with its inclusive cursor; an omitted `throughSeq` selects the observed log end. */ @Remote('page') - page(request: SessionPageRequest, signal: AbortSignal): Promise { - return this.history.page(request, signal) + page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.historyController.events(request, signal) + } + + /** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. + * @param signal - cancellation for persistence reads. + * @returns one chronological page with its inclusive cursor. + */ + @Remote + events(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.historyController.events(request, signal) + } + + /** + * Subscribe to Session events without reading a snapshot. + * @param request - Session address. + * @param signal - subscription cancellation. + * @returns readiness and subsequent events, without a snapshot. + */ + @Remote({ mode: 'stream' }) + observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable { + return this.historyController.observeEvent(request, signal) + } + + /** + * Subscribe to sparse Session projection changes. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns readiness and sparse projection changes. + */ + @Remote({ mode: 'stream' }) + observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable { + return this.historyController.observeProjections(request, signal) + } + + /** + * Read passive Session state. + * @param request - target Session. + * @param signal - read cancellation. + * @returns current passive state. + */ + @Remote + state(request: SessionStateRequest, signal: AbortSignal): Promise { + return this.stateController.state(request, signal) + } + + /** + * Subscribe to complete Session state replacements. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns complete passive state replacements. + */ + @Remote({ mode: 'stream' }) + observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable { + return this.stateController.observeState(request, signal) + } + + /** + * Wait for Session quiescence or pending input with bounded assistant output. + * @param request - Session, wait bounds and output window. + * @param signal - caller cancellation. + * @returns stopping state and bounded assistant output. + */ + @Remote + wait(request: SessionWaitRequest, signal: AbortSignal): Promise { + return this.stateController.wait(request, signal) } /** @@ -525,7 +605,7 @@ export class SessionController extends TypertRemoteService { */ @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { - return this.history.follow(request, signal) + return this.webController.follow(request, signal) } /** @@ -535,49 +615,8 @@ export class SessionController extends TypertRemoteService { * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ @Remote('projections') - async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { - const { sessionId } = request - if (sessionId.length === 0) { - throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {}) - } - try { - signal.throwIfAborted() - const live = this.liveProjections(sessionId) - if (live !== undefined) return live - const stored = await this.ctx.get('sessionPersistence')?.stat(sessionId, { signal }) - signal.throwIfAborted() - const attached = this.liveProjections(sessionId) - if (attached !== undefined) return attached - if (stored === undefined) return null - if (stored.formatStatus === 'migration-required') { - const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(stored.header) - return { kind: 'migration-required', values: { ...cached?.values } as SessionProjectionValues } - } - using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal }) - const projections = observation.projections - if (projections === undefined) { - throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {}) - } - return { kind: 'sequenced', asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues } - } catch (error: unknown) { - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - return this.liveProjections(sessionId) ?? null - } - if (signal.aborted || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { - throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error }) - } - if (error instanceof RemoteError) throw error - throw new RemoteError('gateway/internal', 'Session projection read failed', {}, { cause: error }) - } - } - - private liveProjections( - sessionId: SessionId, - ): Extract | undefined { - const session = this.ctx.sessions.get(sessionId) - if (session === undefined) return undefined - const snapshot = this.ctx.sessionProjections.snapshot(session) - return { kind: 'sequenced', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } + projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { + return this.historyController.projections(request, signal) } /** diff --git a/packages/api/session-controller/src/state/controller.ts b/packages/api/session-controller/src/state/controller.ts new file mode 100644 index 0000000000..60b97776d9 --- /dev/null +++ b/packages/api/session-controller/src/state/controller.ts @@ -0,0 +1,221 @@ +/** Passive Session state, coalesced observations and quiescence waits. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-api-gateway' +import type { UserMessage } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import type { HistoryController } from '../history/controller.ts' +import { DEFAULT_MAX_MESSAGES, rejectNotFound } from '../history.ts' +import type { SessionPendingInput, SessionQueuedItem, SessionState, SessionStateRequest, SessionWaitRequest, SessionWaitValue } from '../types.ts' + +import { StateListener } from './listener.ts' +import type { VersionedSessionState } from './listener.ts' + +const DEFAULT_WAIT_TIMEOUT_MS = 30_000 + +/** Reads and waits without activating or cancelling Session execution. */ +export class StateController { + + /** + * @param ctx - live Session and pending Remote event services. + * @param historyController - passive read entry used for cold state and its bounded output. + */ + constructor(private readonly ctx: Context, private readonly historyController: HistoryController) {} + + /** + * Read passive execution and pending-input state. + * @param request - Session identity. + * @param signal - read cancellation. + * @returns current passive state. + */ + async state(request: SessionStateRequest, signal: AbortSignal): Promise { + return this.readState(request.sessionId, signal) + } + + /** + * Observe complete passive state replacements. + * @param request - Session identity. + * @param signal - observation cancellation. + * @returns complete state replacements, starting with the current state. + */ + async *observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable { + using listener = this.listen(request.sessionId, signal) + for await (const { state } of listener.observeStates()) yield state + } + + /** + * Wait for quiescence, pending input, or the deadline without cancelling execution. + * @param request - Session identity, timeout and assistant output window. + * @param signal - cancellation of this wait only, never of Agent execution. + * @returns the stopping reason and last complete state; no state before timeout is an error. + */ + async wait(request: SessionWaitRequest, signal: AbortSignal): Promise { + const timeoutMs = request.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS + const afterSeq = request.afterSeq ?? -1 + const maxMessages = request.maxMessages ?? DEFAULT_MAX_MESSAGES + if (!Number.isSafeInteger(afterSeq) || afterSeq < -1 || Object.is(afterSeq, -0)) { + throw new RemoteError('gateway/bad-request', 'afterSeq must be an integer greater than or equal to -1', {}) + } + if (!Number.isSafeInteger(maxMessages) || maxMessages <= 0) { + throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) + } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) { + throw new RemoteError('gateway/bad-request', 'timeoutMs must be a positive supported timer delay', {}) + } + using timer = deadline(signal, timeoutMs, 'session/wait-timeout') + using listener = this.listen(request.sessionId, timer.signal) + const sessionId = request.sessionId + const statesGenerator = listener.observeStates(1_000) + let nextStatePromise = statesGenerator.next() + let currentState: SessionState | undefined + let reason: SessionWaitValue['reason'] = 'timeout' + try { + for (;;) { + const observedState = await nextStatePromise + if (observedState.done) { + timer.signal.throwIfAborted() + throw new RemoteError('session/observation-ended', 'Session observation ended before a stopping state', {}) + } + nextStatePromise = statesGenerator.next() + // Observe rejection on immediate exits without replacing the original demand. + void nextStatePromise.catch(() => undefined) + // Only a state the listener still confirms may drive this wait; an invalidated + // sample never becomes the state a timeout reports. + if (!listener.isCurrent(observedState.value.revision)) continue + currentState = observedState.value.state + const action = computeWaitAction(currentState) + if (action === 'continue') continue + if (action === 'input-required' || action === 'idle') { + reason = action + break + } + + const agent = this.ctx.agents.get(sessionId) + if (agent === undefined || this.ctx.sessions.get(sessionId) !== agent.session) continue + if (await waitForIdleOrStateChange(agent, nextStatePromise) === 'state-changed') continue + if (!listener.isCurrent(observedState.value.revision) || this.ctx.agents.get(sessionId) !== agent + || this.ctx.sessions.get(sessionId) !== agent.session) continue + reason = 'idle' + break + } + } catch (error: unknown) { + // A caller cancellation is never a wait outcome; the deadline absorbs only the read + // it cancelled, and any other failure keeps its own error. + if (signal.aborted) throw signal.reason + if (!expired(timer.signal, error)) throw error + } finally { + // Wake/cancel a pending read before asking the generator to return. + listener[Symbol.dispose]() + await nextStatePromise.catch(() => undefined) + await statesGenerator.return(undefined) + } + signal.throwIfAborted() + if (currentState === undefined) throw new RemoteError('session/wait-timeout', 'No Session state arrived before the wait deadline', {}) + const output = await this.historyController.assistantOutput( + sessionId, afterSeq, currentState.cursor, maxMessages, signal, + ) + return { reason, state: currentState, output } + } + + private listen(sessionId: SessionId, signal: AbortSignal): StateListener { + return new StateListener(this.ctx, sessionId, signal, readSignal => this.readState(sessionId, readSignal)) + } + + /** + * @param sessionId - Session to sample. + * @param signal - read cancellation. + * @param retried - whether a live source already redirected this read once. + * @returns one passive state sample. + */ + private async readState(sessionId: SessionId, signal: AbortSignal, retried = false): Promise { + signal.throwIfAborted() + const agent = this.ctx.agents.get(sessionId) + if (agent !== undefined) { + if (agent.session.header.cwd === undefined) rejectNotFound({ kind: 'session', sessionId }) + return { + cursor: agent.session.seq - 1, + active: true, + running: agent.status === 'running', + queue: queueItems(agent), + pendingInput: this.pendingInput(agent.session.id), + } + } + const state = await this.historyController.usingObservation({ sessionId, projection: false }, signal, (observation) => { + // A live source means the Session attached during this read; report its live state + // rather than a live cursor paired with an inactive sample. + if (!retried && observation.source === 'live') return undefined + if (observation.header.cwd === undefined) rejectNotFound({ kind: 'session', sessionId }) + return { + cursor: observation.cursor, + active: false, + running: false, + queue: null, + pendingInput: this.pendingInput(observation.header.id), + } + }) + signal.throwIfAborted() + return state ?? this.readState(sessionId, signal, true) + } + + private pendingInput(sessionId: SessionId): readonly SessionPendingInput[] { + const kinds = new Set() + for (const { event, agentId } of this.ctx.typertGateway.getPendingRemoteEvents()) { + if (String(agentId) !== sessionId) continue + kinds.add(event === 'approval/request' ? 'approval' : event === 'user-questions/request' ? 'question' : 'other') + } + return [...kinds] + } +} + +/** + * @param signal - wait deadline signal. + * @param error - failure raised by the in-flight read. + * @returns whether the deadline expired and cancelled that read. + */ +function expired(signal: AbortSignal, error: unknown): boolean { + const reason = timeoutOf(signal, 'session/wait-timeout') + if (reason === undefined) return false + // Cancellation carries the deadline reason along its cause chain; any other failure does not. + let current: unknown = error + for (let depth = 0; depth < 4 && current instanceof Error; depth++) { + if (current === reason || current.cause === reason) return true + current = current.cause + } + return error === reason +} + +/** Choose the next wait action; check-idle requires the caller's whenIdle and identity checks. */ +function computeWaitAction(state: SessionState): 'input-required' | 'continue' | 'idle' | 'check-idle' { + if (state.pendingInput.length > 0) return 'input-required' + if (state.running) return 'continue' + if (!state.active) return 'idle' + return 'check-idle' +} + +/** Wait for either driver quiescence or the already-armed next state; preserve rejections from both. */ +function waitForIdleOrStateChange( + agent: Agent, + nextStatePromise: Promise>, +): Promise<'idle' | 'state-changed'> { + return Promise.race([ + agent.whenIdle().then(() => 'idle' as const), + nextStatePromise.then(() => 'state-changed' as const), + ]) +} + +function queueItems(agent: Agent): SessionQueuedItem[] { + const item = (message: UserMessage, placement: SessionQueuedItem['placement']): SessionQueuedItem => ({ + id: message.id, + placement, + ...message.source.kind === 'user' && 'rpcId' in message.source ? { rpcId: message.source.rpcId } : {}, + content: message.content as typeof message.content & readonly JsonValue[], + }) + return [ + ...agent.inbox.nextTurn.map(message => item(message, 'queued')), + ...agent.inbox.nextStep.map(message => item(message, message.source.kind === 'user' ? 'steering' : 'context')), + ] +} diff --git a/packages/api/session-controller/src/state/listener.ts b/packages/api/session-controller/src/state/listener.ts new file mode 100644 index 0000000000..15633a7cd8 --- /dev/null +++ b/packages/api/session-controller/src/state/listener.ts @@ -0,0 +1,116 @@ +/** Coalesced Session state observations with Host-local revisions. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionState } from '../types.ts' + +/** One complete state with its listener-local revision. */ +export interface VersionedSessionState { state: SessionState; revision: number } + +/** Coalesces Session/Agent invalidations with local revisions; pending events are sampled on reads. */ +export class StateListener implements Disposable { + private readonly disposes: (() => void)[] = [] + private readonly cancellation = new AbortController() + private readonly signal: AbortSignal + private revision = 0 + private resolver = createDeferredSignal() + private closed = false + + /** + * @param ctx - lifecycle notification owner. + * @param target - Session to observe. + * @param signal - caller cancellation. + * @param readState - passive complete-state reader. + */ + constructor( + ctx: Context, target: SessionId, signal: AbortSignal, + private readonly readState: (signal: AbortSignal) => Promise, + ) { + signal.throwIfAborted() + this.signal = AbortSignal.any([signal, this.cancellation.signal]) + const notify = (id: string | undefined): void => { + if (id !== target || this.closed) return + this.revision++ + const resolver = this.resolver + this.resolver = createDeferredSignal() + resolver.resolve() + } + const close = (): void => { this[Symbol.dispose]() } + signal.addEventListener('abort', close, { once: true }) + this.disposes.push(() => { signal.removeEventListener('abort', close) }) + try { + this.disposes.push(ctx.on('session/event', (session) => { notify(session.id) }, { global: true })) + this.disposes.push(ctx.on('session/created', (session) => { notify(session.id) }, { global: true })) + this.disposes.push(ctx.on('session/disposed', (session) => { notify(session.id) }, { global: true })) + this.disposes.push(ctx.on('agent/status', ({ agent }) => { notify(agent.id) }, { global: true })) + this.disposes.push(ctx.on('agent/created', ({ agent }) => { notify(agent.id) }, { global: true })) + this.disposes.push(ctx.on('agent/disposed', ({ agent }) => { notify(agent.id) }, { global: true })) + const dispose = ctx.effect(() => close, 'session-controller.state-listener') + this.disposes.push(() => { void dispose() }) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** + * Check whether a completed read still represents the latest invalidation. + * @param revision - revision captured with a completed read. + * @returns whether no intervening invalidation occurred. + */ + isCurrent(revision: number): boolean { return !this.closed && revision === this.revision } + + /** + * Read coalesced state replacements with optional periodic refresh. + * @param refreshMs - optional idle recheck interval; omitted for event-only observation. + * @returns complete state replacements with internal consistency revisions. + */ + async *observeStates(refreshMs?: number): AsyncGenerator { + let lastRevision = -1 + try { + while (!this.closed) { + if (lastRevision === this.revision) { + if (refreshMs === undefined) { + await this.resolver.promise + } else { + let timer: ReturnType | undefined + try { + await Promise.race([ + this.resolver.promise, + new Promise((resolve) => { timer = setTimeout(resolve, refreshMs) }), + ]) + } finally { + clearTimeout(timer) + } + if (this.isCurrent(lastRevision)) { + this.revision++ + const resolver = this.resolver + this.resolver = createDeferredSignal() + resolver.resolve() + } + } + } + if (!this.isCurrent(this.revision)) return + lastRevision = this.revision + const state = await this.readState(this.signal) + if (this.isCurrent(lastRevision)) yield { state, revision: lastRevision } + } + } finally { + this[Symbol.dispose]() + } + } + + [Symbol.dispose](): void { + if (this.closed) return + this.closed = true + this.cancellation.abort() + this.resolver.resolve() + for (const dispose of this.disposes.splice(0).reverse()) dispose() + } +} + +function createDeferredSignal(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 8c6da77a60..e3e2ea375a 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -3,7 +3,7 @@ import type {} from '@deepseek-ai/dsh-agent/types' import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command/types' import type { - AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, + ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, } from '@deepseek-ai/dsh-attachment' import type { Branded } from '@deepseek-ai/dsh-brand' import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -175,6 +175,10 @@ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { + /** A wait observation ended before a stopping state; the caller should reread. */ + 'session/observation-ended': Record + /** No state arrived before the deadline; a timeout after a state returns its value instead. */ + 'session/wait-timeout': Record 'session/provider-credentials-unavailable': Record 'session/provider-models-unavailable': { readonly provider: string } 'session/model-unavailable': { readonly provider: string; readonly model: string } @@ -329,12 +333,14 @@ export interface SessionPromptValue { /** Durable image read request. */ export interface SessionAttachmentRequest { + /** Gateway permission scope only; neither Session existence nor its image references are checked. */ readonly sessionId: SessionId - readonly attachmentId: AttachmentIdType + readonly attachment: ImageAttachmentRef } /** Durable image read response value. */ export interface SessionAttachmentValue { + /** Supplied reference after ID, format, byte length, and dimensions verification; display fields are preserved. */ readonly attachment: ImageAttachmentRef readonly data: string } @@ -459,8 +465,11 @@ export interface SessionWireEvent { /** One message-aligned backwards-history request. */ export interface SessionPageRequest { readonly address: SessionAddress - /** Inclusive log cut obtained from the corresponding follow opening frame. */ - readonly throughSeq: number + /** + * Inclusive log cut; omission selects the observed log end. A follow opening + * reports the cut it covered so a caller can pin the same point. + */ + readonly throughSeq?: number readonly beforeSeq?: number readonly maxMessages?: number /** Stop at a Turn start after both minima, unless maxMessages or history exhaustion wins. */ @@ -475,6 +484,8 @@ export interface SessionPageRequest { /** One live event request for a durable Session address. */ export interface SessionFollowRequest extends Pick { readonly address: SessionAddress + /** Request background activation; omission leaves the read passive. */ + readonly activate?: boolean /** Include process-local assistant presentation frames for the Web client. */ readonly assistantStream?: true } @@ -569,6 +580,84 @@ export type SessionControlFrame = | { readonly type: 'baseline'; readonly value: SessionControlBaseline } | ({ readonly type: 'projection' } & SessionProjectionUpdate) +/** One pending live-Agent inbox item, preserving placement and content order. */ +export interface SessionQueuedItem { + readonly id: MessageId + readonly placement: 'queued' | 'steering' | 'context' + readonly rpcId?: SessionRequestId + readonly content: readonly JsonValue[] +} + +/** State-owned classification of pending forwarded Remote events. */ +export type SessionPendingInput = 'approval' | 'question' | 'other' + +/** Passive Session state; a missing live Agent has no readable queue. */ +export interface SessionState { + readonly cursor: number + readonly active: boolean + readonly running: boolean + readonly queue: readonly SessionQueuedItem[] | null + /** + * Pending Remote requests sampled when this state was read. A pending change alone does not + * trigger `observeState`; `wait` refreshes on its own interval. + */ + readonly pendingInput: readonly SessionPendingInput[] +} + +/** Session identity for a passive state read or subscription. */ +export interface SessionStateRequest { + readonly sessionId: SessionId +} + +/** Wait bounds and assistant output window; timeout omission uses the fixed 30-second default. */ +export interface SessionWaitRequest extends SessionStateRequest { + readonly timeoutMs?: number + /** Exclusive output cursor; omission includes all available assistant records. */ + readonly afterSeq?: number + /** Positive assistant-record limit; omission uses the history message budget. */ + readonly maxMessages?: number +} + +/** Assistant messages after afterSeq through the stopping state's cursor, newest first. */ +export interface SessionWaitOutput { + readonly records: readonly SessionHistoryRecord[] + /** More eligible assistant messages exist in the selected interval. */ + readonly truncated: boolean +} + +/** A stopping condition, the last complete passive state and its bounded assistant output. */ +export interface SessionWaitValue { + readonly reason: 'idle' | 'input-required' | 'timeout' + readonly state: SessionState + readonly output: SessionWaitOutput +} + +/** + * One `session.events` page (or its deprecated `page` alias) with its inclusive cut: + * the supplied `throughSeq`, otherwise the log end it was read at. + */ +export interface SessionEventsPage extends SessionPage { + readonly cursor: number +} + +/** Address for a passive event subscription. */ +export interface SessionObserveEventRequest { + readonly address: SessionAddress +} + +/** Readiness confirms subscription only; event continuity starts with the first delivered event. */ +export type SessionEventFrame = + | { readonly type: 'ready' } + | SessionEventEntry + +/** One sparse per-key replacement; different keys can share a seq. */ +export type SessionProjectionChange = { readonly type: 'projection' } & Omit + +/** Subscription readiness without a baseline, followed by per-key changes. */ +export type SessionProjectionFrame = + | { readonly type: 'ready' } + | SessionProjectionChange + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/api/session-controller/src/web/controller.ts b/packages/api/session-controller/src/web/controller.ts new file mode 100644 index 0000000000..7e849e4cca --- /dev/null +++ b/packages/api/session-controller/src/web/controller.ts @@ -0,0 +1,78 @@ +/** Web opening aggregation over one observation and a shared transforming buffer. */ + +import type { Context } from '@deepseek-ai/cordis' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import type { HistoryController } from '../history/controller.ts' +import { addressId, rejectNotFound } from '../history.ts' +import { EventListener, ListenerBuffer } from '../history/listener.ts' +import { history, projections, resolveHistoryRequest, validateObservationAddress } from '../history/controller.ts' +import type { SessionFollowFrame, SessionFollowRequest } from '../types.ts' + +/** Combines opening data and consumes transformed listener output from one queue. */ +export class WebController { + + /** + * @param ctx - Host context carrying Session queries and identity projections. + * @param historyController - passive reading and listener construction. + * @param promote - background activation requested before an opening snapshot is delivered. + */ + constructor( + private readonly ctx: Context, + private readonly historyController: HistoryController, + private readonly promote: (observation: SessionObservation) => void, + ) {} + + /** + * Open a history snapshot and follow uncovered history and assistant notifications. + * @param request - Session identity, page budget, assistant output and optional activation (default false). + * @param signal - cancellation for opening and streaming. + * @returns an opening snapshot followed by uncovered queued increments. + */ + async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { + const sessionId = addressId(request.address) + const spec = resolveHistoryRequest(request) + using queue = new ListenerBuffer(this.ctx, signal) + using listener = new EventListener(this.ctx, sessionId, signal, queue) + const streams = this.historyController.streamsListener + using assistantListener = request.assistantStream === true + ? streams.bind(sessionId, signal, queue) + : undefined + using observation = await this.historyController.usingObservation({ sessionId, projection: true }, signal) + .catch((error: unknown) => { + if (remoteErrorOf(error)?.code === 'session/not-found') rejectNotFound(request.address) + throw error + }) + validateObservationAddress(request.address, observation) + let opening: SessionFollowFrame = { + type: 'snapshot', + header: { ...observation.header }, + projections: projections(observation), + ...history(observation, spec), + } + listener.setCursor(opening.cursor) + if (assistantListener !== undefined) { + const { baseline: assistantStream, ordinal } = assistantListener.snapshot() + opening = { ...opening, assistantStream } + assistantListener.setOrdinalCut(ordinal) + } + // Increments come from the queue, so the opening ends this lease in every branch; + // activation continues on its own retained lease. Promotion starts before the opening is + // delivered, so a consumer that stops after the snapshot still gets the activation it asked for. + if (request.activate === true && !signal.aborted + && observation.header.origin !== 'subagent' && observation.source === 'prepared') { + const promotion = observation.retain() + observation[Symbol.dispose]() + try { + this.promote(promotion) + } catch (error: unknown) { + promotion[Symbol.dispose]() + throw error + } + } else { + observation[Symbol.dispose]() + } + yield opening + yield* queue.items() + } +} diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts index b2abdc2dc2..20a0f08065 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import type { PromptContentPart as AttachmentPromptContentPart } from '@deepseek-ai/dsh-attachment/types' -import { SessionSeq, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import { MutableSessionEventSource, type SessionAssistantSettlementEntry, type SessionLiveEventEntry, type SessionTransientEventEntry, @@ -11,6 +11,7 @@ import type { ProjectionsBaseline } from '../src/client/sessions/projection-stor import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import type { PromptContentPart as SessionPromptContentPart, SessionPageRequest } from '../src/types.ts' import { ev, plainTurn } from './event-script.client.ts' +import { history, pageRule } from './remote/session.client.ts' type RenameSuccess = Extract>, { readonly ok: true }> @@ -70,7 +71,21 @@ describe('Client Session contracts', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().parameter(2).toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it('returns the current page cursor when throughSeq is omitted and preserves explicit cuts', async () => { + const address = { kind: 'session', sessionId: 'page-fixture' as SessionId } as const + const rule = pageRule(history(plainTurn(SessionSeq(0), 1, 'question', 'answer'))) + const current = await rule({ address }) + expect(current).toMatchObject({ ok: true, value: { cursor: 5, hasMore: false } }) + const cut = await rule({ address, throughSeq: 2 }) + expect(cut).toMatchObject({ ok: true, value: { cursor: 2, hasMore: false } }) + if (!cut.ok) throw new Error('expected a successful history page') + expect(cut.value.records.map(record => record.event.seq)).toEqual([0, 1, 2]) + await expect(rule({ address, throughSeq: -1 })).resolves.toEqual({ + ok: true, value: { cursor: -1, records: [], hasMore: false }, + }) }) it('keeps its text and image prompt parts identical to attachment intake', () => { diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 7d46edda4e..f575583063 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -6,19 +6,19 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' +import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { - SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq, + Session, SessionId, SessionLogOffset, } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' +import type { UserMessage } from '@deepseek-ai/dsh-session' import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' import { unsupportedModel, createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' -import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' +import { installSessionReadTestServices } from './test-remote.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -324,277 +324,68 @@ describe('Session queue commands', () => { }) }) -function imageRef(id: string): ImageAttachmentRef { - return { - attachmentId: AttachmentId(id), - mediaType: 'image/png', - bytes: 1, - width: 1, - height: 1, - } -} - -function event(type: string, seq: SessionSeq, data: unknown): SessionEvent { - const surface = ['user/message', 'system/message', 'developer/message', 'tool/result'].includes(type) - return { type, seq, time: seq + 1, data, ...surface ? { surfaceOp: 'append' } : {} } as SessionEvent -} - -async function persistedController( - events: SessionEvent[], - readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>, - workspace: Pick = { cwd: '/workspace' }, -): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> { - const ctx = new Context() - await ctx.plugin(SessionStore) - const sessionId = SessionId('cold-attachment') - const meta: SessionHeader = { - version: SESSION_FORMAT_VERSION, - id: sessionId, - createdAt: 1, - ...workspace, - isSeeded: false, - } - ctx.provide('sessionPersistence', testSessionPersistence(ctx, { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ - meta, - inheritedEventCount: SessionLogOffset(0), - events, - }), - }) as never) - installSessionReadTestServices(ctx) - ctx.provide('attachments', { readImage } as never) - const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController - return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId } -} - -describe('Session attachment authorization', () => { - it.each([ - ['system/message', 'message'], ['developer/message', 'message'], ['tool/result', 'message'], - ['team/message/queued', 'message'], ['tool/ptc-dispatch', 'content'], - ['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'], - ] as const)('reads the declared %s %s content without rewriting it', async (type, field) => { - const ref = imageRef(`${type}-${field}`) - const content = [{ type: 'image', attachment: ref }] - const message = type === 'team/message/queued' ? { content } - : type === 'tool/result' ? { - id: 'declared-message', role: 'tool', source: { kind: 'tool', callId: 'declared-call' }, - toolCallId: 'declared-call', isError: false, content, - } : { - id: 'declared-message', role: type === 'developer/message' ? 'developer' : 'system', - source: { kind: 'system-prompt' }, content, - } - const data = field === 'message' ? { message } : { [field]: content } - const events = [event(type, SessionSeq(0), data)] - const saved = JSON.stringify(events) - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController(events, readImage) +describe('Session attachment reads', () => { + it.each(['commands-session', 'other-session', 'missing-session'])('reads a supplied reference without looking up history for %s', async (id) => { + const { ctx, controller, agent, resolveAgent } = await commandHarness() + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + mediaType: 'image/png', bytes: 2, width: 1, height: 1, + } + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'image', attachment: ref }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) + ctx.provide('attachments', { readImage } as never) + const failHistory = (): never => { throw new Error('image reads must not access Session history') } + const get = vi.spyOn(ctx.sessions, 'get').mockImplementation(failHistory) + const snapshot = vi.spyOn(agent.session, 'snapshotEvents').mockImplementation(failHistory) + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(failHistory) try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .resolves.toEqual({ attachment: ref, data: 'AQ==' }) + await expect(controller.attachment({ sessionId: SessionId(id), attachment: ref })) + .resolves.toEqual({ attachment: ref, data: 'AQI=' }) expect(readImage).toHaveBeenCalledExactlyOnceWith(ref) - expect(JSON.stringify(events)).toBe(saved) + expect(get).not.toHaveBeenCalled() + expect(snapshot).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + expect(resolveAgent).not.toHaveBeenCalled() } finally { - await fixture.ctx.fiber.dispose() + get.mockRestore() + snapshot.mockRestore() + observe.mockRestore() + await ctx.fiber.dispose() } }) - it('does not authorize an attachment through malformed inbox entries', async () => { - const ref = imageRef('not-referenced') - for (const value of [undefined, null, {}, [null, 1, []]]) { - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController([event('agent/inbox/spliced', SessionSeq(0), { inserted: value })], readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - } - }) - - it('ignores unrelated fields of a known message event and its content blocks', async () => { - const ref = imageRef('not-a-content-occurrence') - const content = [{ type: 'image', attachment: ref }] - const stored = event('user/message', SessionSeq(0), { - id: 'metadata', role: 'user', source: { kind: 'user' }, - content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }], - message: { content }, inserted: [{ content }], - }) - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController([stored], readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/attachment-invalid' }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it.each(['external/image-record', 'session/title-llm-request'])('denies attachment access from %s without an attachment carrier', async (type) => { - const ref = imageRef('opaque-event-image') - const content = [{ type: 'image', attachment: ref }] - const opaque = { ...event(type, SessionSeq(0), { - content, message: { content }, inserted: [{ content }], messages: [{ content }], - stream: [{ type: 'chunk', time: 1, chunk: { type: 'block-end', index: 0, block: content[0] } }], - }), ignorable: true as const } - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController([opaque], readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it('finds references in direct, message, inserted, and streamed content', async () => { - const direct = imageRef('direct') - const message = imageRef('message') - const inserted = imageRef('inserted') - const streamed = imageRef('streamed') - const events: SessionEvent[] = [ - { ...event('user/message', SessionSeq(0), { - id: 'direct', role: 'user', source: { kind: 'user' }, - content: [null, [], { type: 'text', text: 'none' }, { type: 'image', attachment: direct }], - }), ignorable: true as const }, - { - type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append', - data: { - turn: 1, - step: 1, - stream: [], - message: createAssistantMessage({ - content: [{ type: 'image', attachment: message }], - source: { provider: 'fixture', model: 'fixture' }, - }), - }, - }, - event('agent/inbox/spliced', SessionSeq(2), { - target: 'next-turn', - start: 0, - inserted: [null, 1, [], { content: [] }, createUserMessage({ - content: [{ type: 'image', attachment: inserted }], - source: { kind: 'user' }, - })], - }), - event('assistant/attempt', SessionSeq(3), { - turn: 1, - step: 1, - stream: [ - { - type: 'chunk', - time: 3, - chunk: { type: 'block-start', index: 0, blockType: 'text' }, - }, - { - type: 'chunk', - time: 3, - chunk: { type: 'block-end', index: 0, block: { type: 'text', text: '' } }, - }, - ], - }), - event('assistant/attempt', SessionSeq(4), { - turn: 1, - step: 1, - stream: [{ - type: 'chunk', - time: 4, - chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } }, - }], - }), - ] - const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) })) - const { ctx, controller, sessionId } = await persistedController(events, readImage) - - for (const ref of [direct, message, inserted, streamed]) { - await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId })) - .resolves.toEqual({ attachment: ref, data: 'AQ==' }) - } - expect(readImage).toHaveBeenCalledTimes(4) - await ctx.fiber.dispose() - }) - - it('maps missing persistence identities and attachment backend failures', async () => { - const noPersistence = new Context() - await noPersistence.plugin(SessionStore) - installSessionReadTestServices(noPersistence) - const noPersistenceController = new SessionCommandController( - noPersistence, - { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, - '/workspace', - ) - await expectFailure(noPersistenceController.attachment({ - sessionId: SessionId('missing'), attachmentId: AttachmentId('att'), - }), 'session/not-found') - - const missing = new Context() - await missing.plugin(SessionStore) - missing.provide('sessionPersistence', testSessionPersistence(missing, { - list: () => Promise.resolve([]), - inspect: vi.fn(), - }) as never) - installSessionReadTestServices(missing) - const missingController = new SessionCommandController( - missing, - { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, - '/workspace', - ) - await expectFailure(missingController.attachment({ - sessionId: SessionId('missing'), attachmentId: 'att' as never, - }), 'session/not-found') - - for (const thrown of [ - new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'), - new Error('backend offline'), - ]) { - const ref = imageRef(`failure-${thrown.name}`) - const fixture = await persistedController( - [event('user/message', SessionSeq(0), { id: 'failure', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })], - () => Promise.reject(thrown), - ) - await expectFailure(fixture.controller.attachment({ - sessionId: fixture.sessionId, - attachmentId: ref.attachmentId, - }), thrown instanceof AttachmentError ? 'session/attachment-invalid' : 'gateway/internal') - await fixture.ctx.fiber.dispose() - } - }) - - it('rejects a cold Session without cwd before reading its referenced image', async () => { - const ref = imageRef('missing-cwd') - const readImage = vi.fn(async () => ({ ref, data: new Uint8Array([1]) })) - const fixture = await persistedController( - [event('user/message', SessionSeq(0), { id: 'image', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })], - readImage, - {}, - ) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/not-found', details: { sessionId: fixture.sessionId } }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it('maps a cold observation failure to an internal authorization error', async () => { + it.each([ + 'INVALID_ATTACHMENT_REF', 'ATTACHMENT_NOT_FOUND', 'ATTACHMENT_CORRUPT', 'ATTACHMENT_READ_FAILED', + ] as const)('returns %s as an explicit attachment failure', async (reason) => { const ctx = new Context() - await ctx.plugin(SessionStore) - installSessionReadTestServices(ctx) - vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) - const controller = new SessionCommandController( - ctx, - { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, - '/workspace', - ) + const readImage = vi.fn(() => Promise.reject(new AttachmentError('Stored image is unavailable.', reason))) + ctx.provide('attachments', { readImage } as never) + const controller = new SessionCommandController(ctx, new ApiSessionAgentController(ctx), '/workspace') + try { + await expect(controller.attachment({ + sessionId: SessionId('unopened'), + attachment: { attachmentId: AttachmentId('requested'), mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason } }) + } finally { + await ctx.fiber.dispose() + } + }) - await expectFailure(controller.attachment({ - sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'), - }), 'gateway/internal') - await ctx.fiber.dispose() + it('contains unexpected backend failures without exposing internal details', async () => { + const ctx = new Context() + ctx.provide('attachments', { readImage: () => Promise.reject(new Error('/private/storage failed')) } as never) + const controller = new SessionCommandController(ctx, new ApiSessionAgentController(ctx), '/workspace') + try { + await expect(controller.attachment({ + sessionId: SessionId('unopened'), + attachment: { attachmentId: AttachmentId('requested'), mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + })) + .rejects.toMatchObject({ code: 'gateway/internal', message: 'Unable to read image attachment.', details: {} }) + } finally { + await ctx.fiber.dispose() + } }) }) diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index a91b9a15c0..664c4f9244 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -3,7 +3,8 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { LlmAttemptId, createUserMessage } from '@deepseek-ai/dsh-llm' +import type TypertGatewayService from '@deepseek-ai/dsh-api-gateway' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' @@ -22,6 +23,75 @@ describe('SessionController facade', () => { expect(SessionController.inject).not.toContain('tools') }) + it('keeps assistant stream observation live while list initialization awaits', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) + const gateway: Partial = { getPendingRemoteEvents: () => [] } + ctx.provide('typertGateway', gateway as TypertGatewayService) + const session = ctx.sessions.create(SessionId('assistant-interleave'), { meta: { cwd: '/workspace' } }) + const partial: Partial = { id: session.id, session, status: 'running', ctx } + const agent = partial as Agent + const entered = Promise.withResolvers() + const gate = Promise.withResolvers() + const original = ctx.sessionProjections.register.bind(ctx.sessionProjections) + let first = true + vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (...args: Parameters) => { + if (first) { + first = false + entered.resolve(undefined) + await gate.promise + } + return original(...args) + }) + const mounting = createSessionTestController(ctx, defaults) + await entered.promise + // An Agent start racing the list read must not be lost before the controller publishes. + ctx.emit('agent/assistant-stream', { + agent, + frame: { type: 'start', attemptId: LlmAttemptId('boot-attempt'), revision: 1, turn: 1, step: 1 }, + }) + gate.resolve(undefined) + const controller = await mounting + const frames = controller.follow({ + address: { kind: 'session', sessionId: session.id }, assistantStream: true, + }, new AbortController().signal)[Symbol.asyncIterator]() + await expect(frames.next()).resolves.toMatchObject({ + value: { type: 'snapshot', assistantStream: { revision: 1 } }, + }) + await frames.return?.(undefined) + }) + + it('serves passive reads, subscriptions and waits through its plain controllers', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) + const gateway: Partial = { getPendingRemoteEvents: () => [] } + ctx.provide('typertGateway', gateway as TypertGatewayService) + const controller = await createSessionTestController(ctx, defaults) + const sessionId = SessionId('passive-facade') + ctx.sessions.create(sessionId, { meta: { cwd: '/workspace' } }) + const address = { kind: 'session' as const, sessionId } + const signal = new AbortController().signal + + await expect(controller.events({ address }, signal)).resolves.toMatchObject({ records: [], hasMore: false }) + const historyFrames = controller.observeEvent({ address }, signal)[Symbol.asyncIterator]() + await expect(historyFrames.next()).resolves.toMatchObject({ value: { type: 'ready' } }) + await historyFrames.return?.(undefined) + const projectionFrames = controller.observeProjections({ sessionId }, signal)[Symbol.asyncIterator]() + await expect(projectionFrames.next()).resolves.toMatchObject({ value: { type: 'ready' } }) + await projectionFrames.return?.(undefined) + await expect(controller.state({ sessionId }, signal)) + .resolves.toMatchObject({ active: false, running: false, queue: null }) + const stateFrames = controller.observeState({ sessionId }, signal)[Symbol.asyncIterator]() + await expect(stateFrames.next()).resolves.toMatchObject({ value: { active: false } }) + await stateFrames.return?.(undefined) + await expect(controller.wait({ sessionId, afterSeq: -1, maxMessages: 10, timeoutMs: 50 }, signal)) + .resolves.toMatchObject({ reason: 'idle' }) + }) + it('owns Host service methods and publishes Agent lifecycle projections', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) @@ -164,6 +234,7 @@ describe('SessionController facade', () => { const abort = new AbortController() const iterator = controller.follow({ address: { kind: 'session', sessionId }, + activate: true, }, abort.signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) @@ -216,6 +287,7 @@ describe('SessionController facade', () => { }) const iterator = controller.follow({ address: { kind: 'session', sessionId }, + activate: true, }, new AbortController().signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) diff --git a/packages/api/session-controller/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts index 0e7eaed7d1..0d9eea291b 100644 --- a/packages/api/session-controller/tests/event-script.client.ts +++ b/packages/api/session-controller/tests/event-script.client.ts @@ -8,7 +8,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEventEntry, - SessionPage, + SessionEventsPage, SessionWireEvent, } from '../src/types.ts' @@ -185,8 +185,9 @@ export function entries(events: readonly SessionEvent[]): SessionEventEntry[] { } /** Build one view-less history response value. */ -export function historyValue(events: readonly SessionEvent[], hasMore = false): SessionPage { +export function historyValue(events: readonly SessionEvent[], hasMore = false): SessionEventsPage { return { + cursor: events.at(-1)?.seq ?? -1, records: entries(events), hasMore, } diff --git a/packages/api/session-controller/tests/history-controller.host.spec.ts b/packages/api/session-controller/tests/history-controller.host.spec.ts new file mode 100644 index 0000000000..e3592ddbad --- /dev/null +++ b/packages/api/session-controller/tests/history-controller.host.spec.ts @@ -0,0 +1,171 @@ +import { Context } from '@deepseek-ai/cordis' +import { createMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HistoryController } from '../src/history/controller.ts' +import { WebController } from '../src/web/controller.ts' +import { installSessionReadTestServices } from './test-remote.ts' + +const roots: Context[] = [] +const aborters: AbortController[] = [] +afterEach(async () => { + for (const abort of aborters.splice(0)) abort.abort() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + vi.restoreAllMocks() +}) +async function fixture() { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const history = new HistoryController(ctx) + const web = new WebController(ctx, history, (observation) => { observation[Symbol.dispose]() }) + const session = ctx.sessions.create(SessionId('history-target'), { meta: { cwd: '/history-test' } }) + const abort = new AbortController() + aborters.push(abort) + const address = { kind: 'session' as const, sessionId: session.id } + return { ctx, history, web, session, abort, address } +} + +describe('HistoryController passive reads', () => { + it('subscribes without reading an Observation and emits later durable events', async () => { + const f = await fixture() + const observe = vi.spyOn(f.ctx.sessionQuery, 'observeSession') + const stream = f.history.observeEvent({ address: f.address }, f.abort.signal)[Symbol.asyncIterator]() + expect(await stream.next()).toEqual({ done: false, value: { type: 'ready' } }) + expect(observe).not.toHaveBeenCalled() + f.session.append('turn/start', { turn: 1 }) + expect(await stream.next()).toMatchObject({ value: { type: 'event', event: { seq: 0 } } }) + f.abort.abort() + expect(await stream.next()).toMatchObject({ done: true }) + }) + + it('subscribes to sparse projection changes without an Observation', async () => { + const f = await fixture() + const observe = vi.spyOn(f.ctx.sessionQuery, 'observeSession') + let receive: Parameters[0] | undefined + const dispose = vi.fn() + vi.spyOn(f.ctx.sessionProjections, 'onChanged').mockImplementation((callback) => { + receive = callback + return dispose + }) + const stream = f.history.observeProjections({ sessionId: f.session.id }, f.abort.signal)[Symbol.asyncIterator]() + expect(await stream.next()).toMatchObject({ value: { type: 'ready' } }) + receive?.(f.session, 'title', 'new title', SessionSeq(0)) + expect(await stream.next()).toMatchObject({ value: { type: 'projection', key: 'title', value: 'new title', seq: 0 } }) + expect(observe).not.toHaveBeenCalled() + f.abort.abort() + expect(await stream.next()).toMatchObject({ done: true }) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('reads newest assistant messages within the exclusive/inclusive interval and detects actual truncation', async () => { + const f = await fixture() + f.session.append('turn/start', { turn: 1 }) + const first = f.session.append('assistant/message', { + turn: 1, step: 1, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'first' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + const second = f.session.append('assistant/message', { + turn: 1, step: 2, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'second' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + f.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const observe = vi.spyOn(f.ctx.sessionQuery, 'observeSession') + const all = await f.history.assistantOutput(f.session.id, -1, 3, 2, f.abort.signal) + expect(all.records.map(record => record.event.seq)).toEqual([second.seq, first.seq]) + expect(all.truncated).toBe(false) + const limited = await f.history.assistantOutput(f.session.id, -1, 3, 1, f.abort.signal) + expect(limited.records.map(record => record.event.seq)).toEqual([second.seq]) + expect(limited.truncated).toBe(true) + const interval = await f.history.assistantOutput(f.session.id, first.seq, second.seq, 2, f.abort.signal) + expect(interval.records.map(record => record.event.seq)).toEqual([second.seq]) + expect(observe).toHaveBeenCalledWith(f.session.id, { signal: f.abort.signal, projectionMode: 'none' }) + await expect(f.history.assistantOutput(f.session.id, -1, 9, 2, f.abort.signal)).rejects.toMatchObject({ code: 'gateway/internal' }) + }) + + it('reads at the observed log end when throughSeq is omitted', async () => { + const f = await fixture() + f.session.append('turn/start', { turn: 1 }) + f.session.append('assistant/message', { + turn: 1, step: 1, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'latest' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + const page = await f.history.events({ address: f.address }, f.abort.signal) + expect(page.cursor).toBe(f.session.seq - 1) + expect(page.records.at(-1)?.event.seq).toBe(f.session.seq - 1) + }) + + it('keeps the append copy a model-facing replacement shadowed', async () => { + const f = await fixture() + f.session.append('turn/start', { turn: 1 }) + const replaced = f.session.append('assistant/message', { + turn: 1, step: 1, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'visible' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + // Compaction rewrites the model-facing surface with a replacement that shadows the original; + // the human-readable transcript keeps the shadowed append copy. + const replacement = f.session.append('user/message', createMessage({ + role: 'user', content: [{ type: 'text', text: 'compacted' }], source: { kind: 'user' }, + }), { surfaceOp: { op: 'replace', startSeq: replaced.seq, endSeq: replaced.seq }, sourceEventSeqs: [replaced.seq] }) + const output = await f.history.assistantOutput(f.session.id, -1, replacement.seq, 5, f.abort.signal) + expect(output.records.map(record => record.event.seq)).toEqual([replaced.seq]) + }) + + it('releases the opening Observation when activation has nothing to promote', async () => { + const f = await fixture() + const source = await f.ctx.sessionQuery.observeSession(f.session.id) + const dispose = vi.fn(() => { source[Symbol.dispose]() }) + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockResolvedValue({ ...source, [Symbol.dispose]: dispose }) + const stream = f.web.follow({ address: f.address, activate: true }, f.abort.signal)[Symbol.asyncIterator]() + await stream.next() + f.abort.abort() + await stream.next() + expect(dispose).toHaveBeenCalled() + }) + + it('releases an acquired Observation when caller cancellation wins its return', async () => { + const f = await fixture() + const source = await f.ctx.sessionQuery.observeSession(f.session.id) + const dispose = vi.fn(() => { source[Symbol.dispose]() }) + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockImplementation(async () => { + f.abort.abort() + return { ...source, [Symbol.dispose]: dispose } + }) + await expect(f.history.usingObservation({ sessionId: f.session.id, projection: false }, f.abort.signal)).rejects.toBeDefined() + expect(dispose).toHaveBeenCalledOnce() + }) + + it('maps a non-Error query cancellation to a cancelled Remote error', async () => { + const f = await fixture() + // A cross-realm or opaque source may reject with the code alone; the message falls back. + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockRejectedValue({ code: 'SESSION_QUERY_ABORTED' }) + await expect(f.history.events({ address: f.address }, f.abort.signal)).rejects.toMatchObject({ + code: 'gateway/cancelled', message: 'Session read was cancelled', + }) + }) + + it('maps missing subagent Follow reads through the Address error', async () => { + const f = await fixture() + const stream = f.web.follow({ address: { + kind: 'subagent', parentSessionId: f.session.id, childSessionId: SessionId('missing-child'), mode: 'continuable', + } }, f.abort.signal)[Symbol.asyncIterator]() + await expect(stream.next()).rejects.toMatchObject({ code: 'subagent/not-found' }) + }) + + it('does not promote a live Session when activation was requested', async () => { + const f = await fixture() + const promote = vi.fn() + const web = new WebController(f.ctx, f.history, promote) + const stream = web.follow({ address: f.address, activate: true }, f.abort.signal)[Symbol.asyncIterator]() + expect(await stream.next()).toMatchObject({ value: { type: 'snapshot' } }) + const pending = stream.next() + f.abort.abort() + expect(await pending).toMatchObject({ done: true }) + expect(promote).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/session-controller/tests/image-read.perf.config.ts b/packages/api/session-controller/tests/image-read.perf.config.ts new file mode 100644 index 0000000000..4b9de690db --- /dev/null +++ b/packages/api/session-controller/tests/image-read.perf.config.ts @@ -0,0 +1,16 @@ +/** Compile the diagnostic's private controller adapter; workspace services remain built package imports. */ + +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { 'image-read.perf': 'image-read.perf.ts' }, + outDir: '../.dsh-build/image-read', + format: 'esm', + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: true, + tsconfig: '../../../../tsconfig.base.json', + deps: { neverBundle: [/^@deepseek-ai\//], onlyBundle: false }, +}) diff --git a/packages/api/session-controller/tests/image-read.perf.ts b/packages/api/session-controller/tests/image-read.perf.ts new file mode 100644 index 0000000000..18a267131a --- /dev/null +++ b/packages/api/session-controller/tests/image-read.perf.ts @@ -0,0 +1,336 @@ +/** Threshold-free, built-runtime measurements of uncached historical image requests. */ + +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { once } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { arch, cpus, platform, release, tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { crc32, deflateSync } from 'node:zlib' +import { Context } from '@deepseek-ai/cordis' +import { LocalAttachmentStore } from '@deepseek-ai/dsh-attachment-local' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { MessageId, ToolCallId } from '@deepseek-ai/dsh-llm' +import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import SessionQueryEngine from '@deepseek-ai/dsh-session-query' +import { ApiSessionAgentController } from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' +import type { SessionAttachmentValue } from '../src/types.ts' + +const WORKLOADS = { + typical: { turns: 120, images: 8 }, + tail: { turns: 2_000, images: 32 }, +} as const +const SAMPLES = 5 +const WIDTH = 1024 +const HEIGHT = 768 + +interface Timings { + historyObservations: number + historyQueries: number + historySnapshots: number + observationMs: number + snapshotMs: number + beforeStoreMs: number + storeMs: number + storeFinished: number + started: number +} + +/** Production point reads; full-text search is outside this diagnostic. */ +class ImageReadSessionQuery extends SessionQueryEngine { + override searchSessions(): Promise { + return Promise.reject(new Error('image read diagnostic does not search sessions')) + } + + override searchEvents(): Promise { + return Promise.reject(new Error('image read diagnostic does not search events')) + } +} + +function image(ordinal: number): Uint8Array { + const pixels = Buffer.alloc((WIDTH * 3 + 1) * HEIGHT) + let seed = ordinal + 1 + for (let y = 0; y < HEIGHT; y++) { + for (let x = 0; x < WIDTH; x++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + const offset = y * (WIDTH * 3 + 1) + 1 + x * 3 + const value = x < WIDTH / 4 ? seed >>> 24 : (Math.floor(x / 16) + Math.floor(y / 16) + ordinal) % 256 + pixels[offset] = value + pixels[offset + 1] = (value + ordinal * 17) % 256 + pixels[offset + 2] = (value + y) % 256 + } + } + const chunk = (name: string, data: Buffer): Buffer => { + const output = Buffer.alloc(data.length + 12) + output.writeUInt32BE(data.length) + output.write(name, 4) + data.copy(output, 8) + output.writeUInt32BE(crc32(output.subarray(4, -4)), output.length - 4) + return output + } + const header = Buffer.alloc(13) + header.writeUInt32BE(WIDTH) + header.writeUInt32BE(HEIGHT, 4) + header[8] = 8 + header[9] = 2 + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', header), chunk('IDAT', deflateSync(pixels)), chunk('IEND', Buffer.alloc(0)), + ]) +} + +function populate(session: Session, turns: number, refs: readonly ImageAttachmentRef[]): void { + for (let turn = 1; turn <= turns; turn++) { + const imageIndex = refs.findIndex((_, ordinal) => Math.floor(ordinal * turns / refs.length) + 1 === turn) + const ref = refs[imageIndex] + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', { + id: MessageId(`input-${turn}`), role: 'user', source: { kind: 'user' }, + content: [ + { type: 'text', text: `Inspect synthetic module ${turn}. ` + 'Describe the visible result. '.repeat(8) }, + ...(ref === undefined ? [] : [{ type: 'image' as const, attachment: ref }]), + ], + }, { surfaceOp: 'append' }) + const callId = ToolCallId(`call-${turn}`) + const call = session.append('tool/call', { turn, step: 1, callId, name: 'synthetic_read', arguments: '{}' }) + session.append('tool/result', { + turn, step: 1, + message: { id: MessageId(`result-${turn}`), role: 'tool', toolCallId: callId, isError: false, + source: { kind: 'tool', callId }, content: [{ type: 'text', text: `module ${turn}\n` + 'export const value = 42;\n'.repeat(80) }] }, + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + const block = { type: 'text' as const, text: `Result ${turn}. ` + 'Synthetic response. '.repeat(40) } + const stream = new AssistantStreamAccumulator() + stream.push({ time: 1_700_000_000_000 + turn, chunk: { type: 'block-start', index: 0, blockType: 'text' } }) + for (let offset = 0; offset < block.text.length; offset += 32) { + stream.push({ time: 1_700_000_000_001 + turn + offset, chunk: { type: 'text-delta', index: 0, text: block.text.slice(offset, offset + 32) } }) + } + stream.push({ time: 1_700_000_001_000 + turn, chunk: { type: 'block-end', index: 0, block } }) + stream.push({ time: 1_700_000_001_001 + turn, chunk: { type: 'finish', reason: { kind: 'stop' } } }) + session.append('assistant/message', { + turn, step: 1, stream: [...stream.snapshot()], + message: { id: MessageId(`reply-${turn}`), role: 'assistant', content: [block], source: { kind: 'model', provider: 'synthetic', model: 'synthetic' } }, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } +} + +function memory(): { heapUsedBytes: number; rssBytes: number; peakRssBytes: number } { + assert.ok(global.gc, 'run the diagnostic with --expose-gc') + global.gc() + const usage = process.memoryUsage() + return { heapUsedBytes: usage.heapUsed, rssBytes: usage.rss, peakRssBytes: process.resourceUsage().maxRSS * 1024 } +} + +async function transfer(payloads: readonly string[]): Promise { + let index = 0 + const server = createServer((_request, response) => { + response.setHeader('content-type', 'application/json') + response.end(payloads[index++]) + }) + try { + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + assert.ok(address !== null && typeof address !== 'string') + const samples: number[] = [] + for (const payload of payloads) { + const start = performance.now() + const response: Response = await fetch(`http://127.0.0.1:${address.port}`, { signal: AbortSignal.timeout(15_000) }) + const received: string = await response.text() + samples.push(performance.now() - start) + assert.equal(received, payload) + } + return samples + } finally { + server.closeAllConnections() + if (server.listening) await new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }) + } +} + +async function measure(workload: keyof typeof WORKLOADS) { + const root = await mkdtemp(join(tmpdir(), 'dsh-image-read-perf-')) + const ctx = new Context() + try { + await ctx.plugin(SessionStore) + await ctx.plugin(ImageReadSessionQuery) + await ctx.plugin(LocalAttachmentStore, { dshHome: root }) + const refs: ImageAttachmentRef[] = [] + for (let ordinal = 0; ordinal < WORKLOADS[workload].images; ordinal++) { + refs.push(await ctx.attachments.saveImage({ data: image(ordinal), mediaType: 'image/png' })) + } + const session = ctx.sessions.create(SessionId('synthetic-image-history'), { meta: { cwd: '/synthetic-image-history' } }) + populate(session, WORKLOADS[workload].turns, refs) + let timing: Timings = { + historyObservations: 0, historyQueries: 0, historySnapshots: 0, + observationMs: 0, snapshotMs: 0, beforeStoreMs: 0, + storeMs: 0, storeFinished: 0, started: 0, + } + let traversedEvents = 0 + let countEvents = false + const observeSession = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery) + ctx.sessionQuery.observeSession = async (...args) => { + timing.historyObservations++ + const start = performance.now() + try { + return await observeSession(...args) + } finally { + timing.observationMs += performance.now() - start + } + } + const getSession = ctx.sessions.get.bind(ctx.sessions) + ctx.sessions.get = (id) => { + timing.historyQueries++ + const found = getSession(id) + return found === undefined ? undefined : new Proxy(found, { + get(target, key) { + const member: unknown = Reflect.get(target, key, target) + if (key !== 'snapshotEvents' || typeof member !== 'function') return member + return (...args: unknown[]) => { + timing.historySnapshots++ + const start = performance.now() + const events = Reflect.apply(member as (...args: unknown[]) => readonly unknown[], target, args) + timing.snapshotMs += performance.now() - start + if (!countEvents) return events + return new Proxy(events, { get(array, property) { + if (property !== Symbol.iterator) { + const value: unknown = Reflect.get(array, property, array) + return value + } + return function* () { for (const event of array) { traversedEvents++; yield event } } + } }) + } + }, + }) + } + const attachments = ctx.attachments + const observedAttachments = new Proxy(attachments, { + get(target, key) { + const member: unknown = Reflect.get(target, key, target) + if (key !== 'readImage' || typeof member !== 'function') return member + return async (...args: unknown[]) => { + const start = performance.now() + timing.beforeStoreMs = start - timing.started + const result: unknown = await Reflect.apply(member, target, args) + timing.storeFinished = performance.now() + timing.storeMs = timing.storeFinished - start + return result + } + }, + }) + const observedContext = new Proxy(ctx, { + get(target, key) { + if (key === 'attachments') return observedAttachments + const value: unknown = Reflect.get(target, key, target) + return value + }, + }) + const controller = new SessionCommandController(observedContext, new ApiSessionAgentController(ctx), '/synthetic-image-history') + const baselineMemory = memory() + const outputs: SessionAttachmentValue[] = [] + const samples = [] + for (const ref of refs) { + timing = { + historyObservations: 0, historyQueries: 0, historySnapshots: 0, + observationMs: 0, snapshotMs: 0, beforeStoreMs: 0, + storeMs: 0, storeFinished: 0, started: performance.now(), + } + const cpu = process.cpuUsage() + const value = await controller.attachment({ sessionId: session.id, attachment: ref }) + const completed = performance.now() + const usedCpu = process.cpuUsage(cpu) + assert.deepEqual(value.attachment, ref) + assert.equal(`sha256:${createHash('sha256').update(Buffer.from(value.data, 'base64')).digest('hex')}`, ref.attachmentId) + samples.push({ + controllerMs: completed - timing.started, + controllerCpuMs: (usedCpu.user + usedCpu.system) / 1000, + historyObservations: timing.historyObservations, + historyQueries: timing.historyQueries, historySnapshots: timing.historySnapshots, + observationMs: timing.observationMs, snapshotMs: timing.snapshotMs, + scanAndDispatchMs: timing.beforeStoreMs - timing.observationMs - timing.snapshotMs, + storeMs: timing.storeMs, base64AndReturnMs: completed - timing.storeFinished, + }) + outputs.push(value) + } + const retainedMemory = memory() + const jsonMs: number[] = [] + const payloads = outputs.map((value) => { + const start = performance.now() + const text = JSON.stringify(value) + jsonMs.push(performance.now() - start) + return text + }) + const loopbackTransferMs = await transfer(payloads) + countEvents = true + timing.historyObservations = 0 + timing.historyQueries = 0 + timing.historySnapshots = 0 + for (const ref of refs) await controller.attachment({ sessionId: session.id, attachment: ref }) + const workCounts = { + historyObservations: timing.historyObservations, historyQueries: timing.historyQueries, + historySnapshots: timing.historySnapshots, traversedEvents, + } + if (process.argv.includes('--expect-no-history')) { + assert.deepEqual(workCounts, { historyObservations: 0, historyQueries: 0, historySnapshots: 0, traversedEvents: 0 }) + } + return { + workload, fixture: { ...WORKLOADS[workload], events: session.seq, width: WIDTH, height: HEIGHT, + imageBytes: refs.map(ref => ref.bytes), attachmentIds: refs.map(ref => ref.attachmentId), + fullReferenceRequestJsonBytes: refs.map(attachment => Buffer.byteLength(JSON.stringify({ sessionId: session.id, attachment }))), + idOnlyRequestJsonBytes: refs.map(ref => Buffer.byteLength(JSON.stringify({ + sessionId: session.id, attachmentId: ref.attachmentId, + }))), + responseJsonBytes: payloads.map(text => Buffer.byteLength(text)) }, + samples, jsonMs, loopbackTransferMs, workCounts, baselineMemory, retainedMemory, + } + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } +} + +for (const specifier of [ + '@deepseek-ai/cordis', '@deepseek-ai/dsh-attachment-local', '@deepseek-ai/dsh-session', '@deepseek-ai/dsh-session-query', +]) { + assert.match(import.meta.resolve(specifier), /\/lib\/.*\.js$/, `${specifier} must resolve to built JavaScript`) +} +assert.ok(import.meta.url.endsWith('.js'), 'compile this diagnostic before running it') +assert.ok(process.execArgv.every(value => !value.includes('tsx')), 'do not use a TypeScript loader') + +const workload = process.argv[3] +if (process.argv[2] === '--sample') { + assert.ok(workload === 'typical' || workload === 'tail') + process.stdout.write(`${JSON.stringify(await measure(workload))}\n`) +} else { + const samples: unknown[] = [] + for (const name of Object.keys(WORKLOADS)) { + for (let sample = 0; sample < SAMPLES; sample++) { + const child = spawnSync(process.execPath, ['--expose-gc', fileURLToPath(import.meta.url), '--sample', name, + ...process.argv.includes('--expect-no-history') ? ['--expect-no-history'] : []], { + encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, NODE_OPTIONS: '', TSX_TSCONFIG_PATH: '' }, + }) + if (child.error !== undefined) throw child.error + assert.equal(child.signal, null, child.stderr) + assert.equal(child.status, 0, child.stderr) + const report: unknown = JSON.parse(child.stdout) + samples.push(report) + } + } + process.stdout.write(JSON.stringify({ + revision: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), + node: process.version, platform: platform(), release: release(), arch: arch(), cpu: cpus()[0]?.model, + clock: 'Fresh child per sample; loaded Session; first uncached request per image; OS file cache may be warm from fixture writes. Store includes file I/O, digest and header validation. observationMs measures the production SessionQuery point observation; snapshotMs measures its lazy event-array acquisition. scanAndDispatchMs is the residual before store entry after subtracting observation and snapshot acquisition. JSON and loopback HTTP transfer are separate measurements over retained responses. Gateway, browser, remote network, upload and model inference are excluded.', + memory: 'GC after setup and after all image responses; Session and all base64 responses remain reachable. Process peak RSS includes fixture creation. No retained-memory budget.', + samples, + }, null, 2) + '\n') +} diff --git a/packages/api/session-controller/tests/listeners.host.spec.ts b/packages/api/session-controller/tests/listeners.host.spec.ts new file mode 100644 index 0000000000..34207efb3c --- /dev/null +++ b/packages/api/session-controller/tests/listeners.host.spec.ts @@ -0,0 +1,263 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Agent, AssistantStreamFrame } from '@deepseek-ai/dsh-agent' +import { LlmAttemptId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventListener, ListenerBuffer, ProjectionListener, eventItem } from '../src/history/listener.ts' +import { SessionAssistantStreamsListener } from '../src/history/assistant-stream.ts' +import type { SessionFollowFrame, SessionEventEntry } from '../src/types.ts' +import { installSessionReadTestServices } from './test-remote.ts' + +const roots: Context[] = [] +const aborts: AbortController[] = [] +function cancellation() { + const abort = new AbortController() + aborts.push(abort) + return abort +} +afterEach(async () => { + for (const abort of aborts.splice(0)) abort.abort() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + vi.restoreAllMocks() +}) +async function fixture() { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const session = ctx.sessions.create(SessionId('listener-target'), { meta: { cwd: '/listeners' } }) + const abort = cancellation() + const partial: Partial = { id: session.id, session } + const agent = partial as Agent + const emit = (frame: AssistantStreamFrame) => { ctx.emit('agent/assistant-stream', { agent, frame }) } + return { ctx, session, abort, emit, agent } +} +const start = (revision = 1): AssistantStreamFrame => ({ type: 'start', attemptId: LlmAttemptId('attempt'), revision, turn: 1, step: 1 }) + +describe('ListenerBuffer', () => { + it('orders heterogeneous inputs lazily, supports prepend, and admits only one consumer', async () => { + const { ctx, abort } = await fixture() + using buffer = new ListenerBuffer(ctx, abort.signal) + const convert = vi.fn((value: number) => [`n:${value}`]) + const numbers = buffer.createInput(convert) + const words = buffer.createInput(value => [value]) + numbers.append(2) + words.prepend('first') + expect(convert).not.toHaveBeenCalled() + const values = buffer.items()[Symbol.asyncIterator]() + expect(await values.next()).toEqual({ done: false, value: 'first' }) + expect(await values.next()).toEqual({ done: false, value: 'n:2' }) + await expect(buffer.items()[Symbol.asyncIterator]().next()).rejects.toMatchObject({ code: 'gateway/internal' }) + const waiting = values.next() + numbers.append(3) + expect(await waiting).toMatchObject({ value: 'n:3' }) + await values.return?.() + const action = vi.fn() + buffer.capture(action) + numbers.append(4) + words.prepend('closed') + expect(action).not.toHaveBeenCalled() + buffer.own(action) + expect(action).toHaveBeenCalledOnce() + expect(() => buffer.createInput(value => [value])).toThrow('closed') + }) + + it('propagates captured failure both before and during transformation', async () => { + const { ctx, abort } = await fixture() + using first = new ListenerBuffer(ctx, abort.signal) + first.capture(() => { throw new Error('captured') }) + await expect(first.items()[Symbol.asyncIterator]().next()).rejects.toThrow('captured') + using second = new ListenerBuffer(ctx, abort.signal) + const input = second.createInput(function* (value) { + second.capture(() => { throw new Error('transform failure') }) + yield value + }) + input.append(1) + await expect(second.items()[Symbol.asyncIterator]().next()).rejects.toThrow('transform failure') + }) + + it('does not emit after transformation closes its own buffer', async () => { + const { ctx, abort } = await fixture() + using buffer = new ListenerBuffer(ctx, abort.signal) + buffer.createInput(function* (value) { buffer[Symbol.dispose](); yield value }).append(1) + expect(await buffer.items()[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) + + it('releases abort registration when effect installation fails', async () => { + const { ctx, abort } = await fixture() + const remove = vi.spyOn(abort.signal, 'removeEventListener') + vi.spyOn(ctx, 'effect').mockImplementation(() => { throw new Error('effect failure') }) + expect(() => new ListenerBuffer(ctx, abort.signal)).toThrow('effect failure') + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + }) +}) + +describe('EventListener', () => { + it('starts at the first notification without acquiring a snapshot', async () => { + const { ctx, session, abort } = await fixture() + using listener = new EventListener(ctx, session.id, abort.signal) + const event = session.append('turn/start', { turn: 1 }) + const values = listener.events()[Symbol.asyncIterator]() + expect(await values.next()).toMatchObject({ value: { type: 'event', event } }) + abort.abort() + expect(await values.next()).toMatchObject({ done: true }) + }) + + it('subscribes before an empty Session is created and follows its first later event', async () => { + const { ctx, abort } = await fixture() + const id = SessionId('created-after-bind') + using listener = new EventListener(ctx, id, abort.signal) + const session = ctx.sessions.create(id) + const values = listener.events()[Symbol.asyncIterator]() + const waiting = values.next() + const event = session.append('turn/start', { turn: 1 }) + expect(await waiting).toMatchObject({ value: { event } }) + await values.return?.() + }) + + it('filters buffered events through a supplied cursor', async () => { + const { ctx, session, abort } = await fixture() + using listener = new EventListener(ctx, session.id, abort.signal) + session.append('turn/start', { turn: 1 }) + const event = session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + listener.setCursor(0) + const values = listener.events()[Symbol.asyncIterator]() + expect(await values.next()).toMatchObject({ value: { event } }) + await values.return?.() + }) + + it('leaves an external buffer open and drops notifications after listener disposal', async () => { + const { ctx, session, abort } = await fixture() + using queue = new ListenerBuffer(ctx, abort.signal) + const listener = new EventListener(ctx, session.id, abort.signal, queue) + session.append('turn/start', { turn: 1 }) + await expect(listener.events()[Symbol.asyncIterator]().next()).rejects.toThrow('externally owned') + listener[Symbol.dispose]() + const values = queue.items()[Symbol.asyncIterator]() + const next = values.next() + abort.abort() + expect(await next).toMatchObject({ done: true }) + }) + + it('stops within a creation batch when its binding is disposed', async () => { + const { ctx, session, abort } = await fixture() + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + using queue = new ListenerBuffer(ctx, abort.signal) + const listener = new EventListener(ctx, session.id, abort.signal, queue) + listener.setCursor(-1) + ctx.emit('session/created', session) + const values = queue.items()[Symbol.asyncIterator]() + expect(await values.next()).toMatchObject({ value: { event: { seq: 0 } } }) + listener[Symbol.dispose]() + const waiting = values.next() + abort.abort() + expect(await waiting).toMatchObject({ done: true }) + }) + + it('cleans a partially installed registration when a later one fails', async () => { + const { ctx, session, abort } = await fixture() + const on = ctx.on.bind(ctx) + let count = 0 + vi.spyOn(ctx, 'on').mockImplementation((...args) => { + if (++count === 2) throw new Error('registration failure') + return on(...args) + }) + expect(() => new EventListener(ctx, session.id, abort.signal)).toThrow('registration failure') + }) + + it('handles raw events and rejects a noncontiguous supplied creation prefix', async () => { + const { session } = await fixture() + const event = session.append('turn/start', { turn: 1 }) + expect(eventItem({ type: 'event', session, event })).toEqual({ events: [event], next: 1 }) + expect(eventItem({ type: 'created', session, end: 0 })).toEqual({ events: [], next: undefined }) + vi.spyOn(session, 'snapshotEvents').mockReturnValue([{ ...event, seq: SessionSeq(1) }]) + expect(() => eventItem({ type: 'created', session, end: 2 }, 0)).toThrow('skipped seq') + expect(eventItem({ type: 'created', session, end: 2 })).toMatchObject({ next: 2 }) + }) +}) + +describe('ProjectionListener', () => { + it('emits sparse changes only for the selected Session', async () => { + const { ctx, session, abort } = await fixture() + let receive!: Parameters[0] + const dispose = vi.fn() + vi.spyOn(ctx.sessionProjections, 'onChanged').mockImplementation((listener) => { receive = listener; return dispose }) + using listener = new ProjectionListener(ctx, session.id, abort.signal) + const other = ctx.sessions.create(SessionId('other')) + receive(other, 'subagent', null, SessionSeq(0)) + receive(session, 'subagent', null, SessionSeq(1)) + const changes = listener.changes()[Symbol.asyncIterator]() + expect(await changes.next()).toMatchObject({ value: { type: 'projection', key: 'subagent', value: null, seq: 1 } }) + abort.abort() + expect(await changes.next()).toMatchObject({ done: true }) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('releases its buffer when projection subscription fails', async () => { + const { ctx, session, abort } = await fixture() + const remove = vi.spyOn(abort.signal, 'removeEventListener') + vi.spyOn(ctx.sessionProjections, 'onChanged').mockImplementation(() => { throw new Error('projection subscribe failed') }) + expect(() => new ProjectionListener(ctx, session.id, abort.signal)).toThrow('projection subscribe failed') + expect(remove).toHaveBeenCalled() + }) +}) + +describe('AssistantStreamListener', () => { + it('cuts baseline-covered arrivals and clears reconnect state on Agent disposal', async () => { + const { ctx, session, abort, emit, agent } = await fixture() + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + using listener = streams.bind(session.id, abort.signal, queue) + expect(listener.snapshot()).toEqual({ baseline: { revision: 0 }, ordinal: 0 }) + emit(start()) + expect(listener.snapshot()).toMatchObject({ baseline: { revision: 1 }, ordinal: 1 }) + listener.setOrdinalCut(1) + const frames = queue.items()[Symbol.asyncIterator]() + const end: AssistantStreamFrame = { type: 'end', attemptId: LlmAttemptId('attempt'), revision: 2, index: 0, outcome: { kind: 'abandoned' } } + emit(end) + expect(await frames.next()).toMatchObject({ value: { type: 'assistant-stream', frame: end } }) + ctx.emit('agent/disposed', { agent }) + expect(listener.snapshot()).toEqual({ baseline: { revision: 0 }, ordinal: 2 }) + abort.abort() + expect(await frames.next()).toMatchObject({ done: true }) + }) + + it('records the durable cursor for an attempt started after a committed event', async () => { + const { ctx, session, abort, emit } = await fixture() + session.append('turn/start', { turn: 1 }) + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + using listener = streams.bind(session.id, abort.signal, queue) + emit(start()) + listener.setOrdinalCut(0) + const frames = queue.items()[Symbol.asyncIterator]() + expect(await frames.next()).toMatchObject({ value: { frame: { type: 'start', startedAfterSeq: 0 } } }) + await frames.return?.() + }) + + it('leaves queue ownership intact and drops notifications after unbinding', async () => { + const { ctx, session, abort, emit } = await fixture() + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + const listener = streams.bind(session.id, abort.signal, queue) + emit(start()) + listener[Symbol.dispose]() + const next = queue.items()[Symbol.asyncIterator]().next() + abort.abort() + expect(await next).toMatchObject({ done: true }) + }) + + it('propagates registration failure without closing the shared queue', async () => { + const { ctx, session, abort } = await fixture() + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + vi.spyOn(ctx, 'on').mockImplementation(() => { throw new Error('assistant subscribe failed') }) + expect(() => streams.bind(session.id, abort.signal, queue)).toThrow('assistant subscribe failed') + // The failed binding leaves the shared queue usable for the rest of the connection. + const input = queue.createInput(value => [value]) + const event = session.append('turn/start', { turn: 1 }) + input.append({ type: 'event', event }) + await expect(queue.items()[Symbol.asyncIterator]().next()).resolves.toMatchObject({ value: { type: 'event', event } }) + }) +}) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 57eb9ecbfa..9661f66a01 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -862,7 +862,7 @@ describe('subagent catalogs', () => { childSessionId: S2, mode: 'continuable', }, - assistantStream: true, + activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 }, }, @@ -958,7 +958,7 @@ describe('subagent catalogs', () => { if (source === 'control') { manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } }) } else { - mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline }))) + mock.stream(FOLLOW, followScript(ok({ cursor: baseline.asOfSeq, records: [], hasMore: false, projections: baseline }))) await session.open() } @@ -983,7 +983,7 @@ describe('subagent catalogs', () => { if (source === 'control') { manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } }) } else { - mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline }))) + mock.stream(FOLLOW, followScript(ok({ cursor: baseline.asOfSeq, records: [], hasMore: false, projections: baseline }))) await manager.get(S1).open() } response.resolve(ok({ kind: 'migration-required', values: { @@ -1545,6 +1545,7 @@ describe('connected generation', () => { it('refreshes query baselines without rebuilding independently resumed Session sources', async ({ mock, remote, start }) => { mock.stream(FOLLOW, followScript(ok({ + cursor: 5, records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts index 150b82d415..539962a622 100644 --- a/packages/api/session-controller/tests/projection-discovery.host.spec.ts +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -239,7 +239,7 @@ describe('Session projection discovery', () => { kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: 'current-child', mode: 'continuable', label: 'worker' }] }, }) - expect(observe).toHaveBeenCalledWith(parent.header.id, { signal }) + expect(observe).toHaveBeenCalledWith(parent.header.id, { signal, projectionMode: 'all' }) expect(open).toHaveBeenCalledWith(parent.header.id, 'read', { signal }) expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined() expect(fixture.ctx.agents.get(parent.header.id)).toBeUndefined() @@ -310,7 +310,7 @@ describe('Session projection discovery', () => { .rejects.toMatchObject({ code: 'session/projections-unavailable' }) }) - it.each([false, true])('rechecks live history when the stored source disappears after preflight (attached: %s)', async (attached) => { + it.each([false, true])('returns the observation not-found result without a second live lookup (attached: %s)', async (attached) => { const fixture = await harness() const parent = await stored(fixture, SessionId('missing-after-preflight')) const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) @@ -322,9 +322,8 @@ describe('Session projection discovery', () => { }) const result = await fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal) - expect(result).toEqual(attached - ? { kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(fixture.ctx.sessions.get(parent.header.id)!) } - : null) + expect(result).toBeNull() + expect(fixture.ctx.sessions.get(parent.header.id) !== undefined).toBe(attached) }) it('serves the live registry cut instead of a conflicting cached hint', async () => { @@ -342,7 +341,7 @@ describe('Session projection discovery', () => { expect(cached).not.toHaveBeenCalled() }) - it.each([true, false])('prefers a Session attached during stat (stored header present: %s)', async (present) => { + it.each([true, false])('uses migration preflight before observing a Session attached during stat (stored header present: %s)', async (present) => { const fixture = await harness() const parent = await stored(fixture, SessionId('stat-race'), {}, 3) const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) @@ -358,7 +357,9 @@ describe('Session projection discovery', () => { const live = fixture.ctx.sessions.create(parent.header.id, { meta: parent.header }) live.append('turn/start', { turn: 1 }) finish.resolve(present ? snapshot : undefined) - await expect(operation).resolves.toEqual({ kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(live) }) + await expect(operation).resolves.toEqual(present + ? { kind: 'migration-required', values: {} } + : { kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(live) }) } finally { finish.resolve(undefined) await Promise.allSettled([operation]) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 40e1f3c95f..ab1e185744 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -214,7 +214,7 @@ describe('Session tail-page seeding', () => { it('treats a blockless response as no reset: pushed values survive', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false }))) + mock.stream(FOLLOW, followScript(ok({ cursor: 5, records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false }))) await session.open() session.projections.apply('test/marks', { marks: ['pushed'] }, SessionSeq(9)) await session.resync() diff --git a/packages/api/session-controller/tests/reference-ownership.client.spec.ts b/packages/api/session-controller/tests/reference-ownership.client.spec.ts index 16de92ef02..b0563eb879 100644 --- a/packages/api/session-controller/tests/reference-ownership.client.spec.ts +++ b/packages/api/session-controller/tests/reference-ownership.client.spec.ts @@ -21,7 +21,7 @@ declare module '@deepseek-ai/dsh-api-session-controller/client' { const viewSource: SessionReferenceSource = 'referenceTestView' const workSource: SessionReferenceSource = 'referenceTestWork' const ID = SessionId('reference-session') -const EMPTY_HISTORY = ok({ records: [], hasMore: false }) +const EMPTY_HISTORY = ok({ cursor: -1, records: [], hasMore: false }) const it = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) }) async function bench(mock: RemoteMock, start: () => Promise, listed = true) { diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts index e15b7b3fb1..8be0dac78e 100644 --- a/packages/api/session-controller/tests/remote-access.host.spec.ts +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -8,6 +8,7 @@ */ import { Context } from '@deepseek-ai/cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' @@ -268,6 +269,34 @@ describe('Session Controller access', () => { ]) }) + it('keeps gateway permission checks while allowing an image referenced only by another Session', async () => { + const { ctx, hidden } = await harness() + await ctx.plugin(TypertGatewayService) + const bindings = guestPolicy(ctx) + const ref = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1, + } + hidden.session.append('user/message', createUserMessage({ + content: [{ type: 'image', attachment: ref }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) + ctx.attachments.readImage = readImage + const read = (sessionId: SessionId): Promise => guestCall(ctx, 'attachment', { + request: { sessionId, attachment: ref }, + }) + await expect(read(SHARED)).rejects.toMatchObject({ code: 'remote-access/denied' }) + expect(readImage).not.toHaveBeenCalled() + bindings.push({ + role: { name: 'test:attachment-reader', resource: 'session', actions: ['session/attachment'] }, + resource: { kind: 'session', id: SHARED }, + }) + await expect(read(SHARED)).resolves.toEqual({ attachment: ref, data: 'AQI=' }) + expect(readImage).toHaveBeenCalledExactlyOnceWith(ref) + await expect(read(PRIVATE)).rejects.toMatchObject({ code: 'remote-access/denied' }) + expect(readImage).toHaveBeenCalledTimes(1) + }) + it('lets a non-owner read projections only in a Session its Session role names', async () => { const { ctx } = await harness() await ctx.plugin(TypertGatewayService) diff --git a/packages/api/session-controller/tests/remote/history.client.ts b/packages/api/session-controller/tests/remote/history.client.ts index e954f89a2b..080571b09d 100644 --- a/packages/api/session-controller/tests/remote/history.client.ts +++ b/packages/api/session-controller/tests/remote/history.client.ts @@ -1,7 +1,7 @@ /** Pure history response builders for assembled Session tests. */ import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' import type { - SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionProjectionBaseline, + SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionEventsPage, SessionProjectionBaseline, } from '../../src/types.ts' import { historyRecordLastSeq } from '../../src/client/sessions/history-records.ts' @@ -11,8 +11,8 @@ import { historyRecordLastSeq } from '../../src/client/sessions/history-records. * @param throughSeq - inclusive final sequence. * @returns the page without records beyond the cursor. */ -export function pageThrough(page: SessionPage, throughSeq: number): SessionPage { - return { ...page, records: page.records.filter(record => historyRecordLastSeq(record) <= throughSeq) } +export function pageThrough(page: SessionPage, throughSeq: number): SessionEventsPage { + return { ...page, cursor: throughSeq, records: page.records.filter(record => historyRecordLastSeq(record) <= throughSeq) } } /** diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts index 591dd60985..f920d1592e 100644 --- a/packages/api/session-controller/tests/remote/session.client.ts +++ b/packages/api/session-controller/tests/remote/session.client.ts @@ -12,7 +12,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, - SessionPage, SessionPageRequest, + SessionEventsPage, SessionPageRequest, } from '../../src/types.ts' import { entries, historyValue } from '../event-script.client.ts' import { followSnapshot, pageThrough } from './history.client.ts' @@ -24,7 +24,7 @@ export const FOLLOW = 'session/follow' export const PAGE = 'session/page' /** A history answer as the Host returns it, possibly still pending. */ -export type HistoryAnswer = RemoteResult | Promise> +export type HistoryAnswer = RemoteResult | Promise> /** A history answer, or a function of the request that produces one. */ export type HistorySource = HistoryAnswer | ((request: Request) => HistoryAnswer) @@ -43,7 +43,7 @@ export function err(error: RemoteFailure): RemoteResult { * @param hasMore - whether older history exists. * @returns the success result. */ -export function history(events: readonly SessionEvent[], hasMore = false): RemoteResult { +export function history(events: readonly SessionEvent[], hasMore = false): RemoteResult { return ok(historyValue(events, hasMore)) } @@ -102,11 +102,11 @@ export function followScript( */ export function pageRule( history: HistorySource, -): UnaryRuleFn>> { +): UnaryRuleFn>> { return async (page) => { const result = await answer(history, page) if (!result.ok) return result - return ok(pageThrough(result.value, page.throughSeq)) + return ok(pageThrough(result.value, page.throughSeq ?? result.value.cursor)) } } @@ -144,12 +144,12 @@ export const sessionWorld: RemoteTable = { attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==', }), - 'session/page': pageRule(ok({ records: [], hasMore: false })), + 'session/page': pageRule(ok({ cursor: -1, records: [], hasMore: false })), 'session/projections': ok({ kind: 'sequenced', asOfSeq: -1, values: { subagentCatalog: [] } }), 'subagents/prompt': ok({ messageId: 'fake-message' }), 'subagents/interruptByParent': ok({ accepted: true }), }, stream: { - 'session/follow': followScript(ok({ records: [], hasMore: false })), + 'session/follow': followScript(ok({ cursor: -1, records: [], hasMore: false })), }, } diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 1bc9297103..560c05514f 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -11,7 +11,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' -import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' @@ -574,10 +573,7 @@ describe('subagent ownership fence', () => { const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) - const history = await new SessionHistoryController( - ctx, - (observation) => { observation[Symbol.dispose]() }, - ).page({ + const history = await ctx.sessionController.events({ address: { kind: 'subagent', parentSessionId: meta.parentSession as SessionId, diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index f1954ed935..328e797677 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -8,7 +8,8 @@ import SessionStore from '@deepseek-ai/dsh-session' import { LlmAttemptId, ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' +import { HistoryController } from '../src/history/controller.ts' +import { WebController } from '../src/web/controller.ts' import type { SessionFollowFrame, SessionPage, SessionWireEvent } from '@deepseek-ai/dsh-api-session-controller/types' import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts' @@ -57,6 +58,12 @@ async function harness(): Promise<{ ctx: Context }> { return { ctx } } +/** Initialize the independent snapshot owner and Web aggregation for direct journal tests. */ +async function controllers(ctx: Context): Promise<{ history: HistoryController; web: WebController }> { + const history = new HistoryController(ctx) + return { history, web: new WebController(ctx, history, (observation) => { observation[Symbol.dispose]() }) } +} + /** Drain one Session follow until `count` event frames arrive. */ async function collect( iterable: AsyncIterable, @@ -73,11 +80,11 @@ async function collect( /** Open follow and wait until its cursor is fixed before appending fixtures. */ async function openFollow( - history: SessionHistoryController, + web: WebController, sessionId: SessionId, signal: AbortSignal, ): Promise> { - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId }, }, signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ @@ -107,9 +114,9 @@ describe('Session history raw journal', () => { it('opens an empty opted-in Assistant baseline before any live attempt exists', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -127,7 +134,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery) const entered = Promise.withResolvers() const release = Promise.withResolvers() @@ -137,7 +144,7 @@ describe('Session history raw journal', () => { return originalObserve(...args) }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -182,7 +189,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('live-follow-attempt') const emit = (frame: AssistantStreamFrame): void => { ctx.emit('agent/assistant-stream', { agent, frame }) @@ -197,7 +204,7 @@ describe('Session history raw journal', () => { time: 1, chunk: firstChunk, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -249,7 +256,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId(`${session.id}:1`) ctx.emit('agent/assistant-stream', { agent, @@ -267,7 +274,7 @@ describe('Session history raw journal', () => { }, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -311,7 +318,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('revision-gap-attempt') ctx.emit('agent/assistant-stream', { agent, @@ -329,7 +336,7 @@ describe('Session history raw journal', () => { }, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -351,7 +358,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('dense-index-attempt') ctx.emit('agent/assistant-stream', { agent, @@ -369,7 +376,7 @@ describe('Session history raw journal', () => { }, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -391,7 +398,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('cached-baseline-attempt') ctx.emit('agent/assistant-stream', { agent, @@ -402,12 +409,12 @@ describe('Session history raw journal', () => { }) const firstAbort = new AbortController() - const firstIterator = history.follow({ + const firstIterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, firstAbort.signal)[Symbol.asyncIterator]() const secondAbort = new AbortController() - const secondIterator = history.follow({ + const secondIterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, secondAbort.signal)[Symbol.asyncIterator]() @@ -431,9 +438,9 @@ describe('Session history raw journal', () => { it('opens an empty Assistant baseline before the target Agent emits frames', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -456,9 +463,9 @@ describe('Session history raw journal', () => { const target = ctx.sessions.create(undefined, { meta: { cwd: '/target' } }) const other = ctx.sessions.create(undefined, { meta: { cwd: '/other' } }) const otherAgent = { id: other.id, session: other, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: target.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -489,7 +496,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const observationStarted = Promise.withResolvers() const releaseObservation = Promise.withResolvers() const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery) @@ -499,7 +506,7 @@ describe('Session history raw journal', () => { return await originalObserve(sessionId, options) }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -539,7 +546,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId(`${session.id}:1`) ctx.emit('agent/assistant-stream', { agent, @@ -557,7 +564,7 @@ describe('Session history raw journal', () => { return await originalObserve(sessionId, options) }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -613,9 +620,9 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, }, abort.signal)[Symbol.asyncIterator]() const opening = await iterator.next() @@ -655,9 +662,9 @@ describe('Session history raw journal', () => { it('follows raw tool events and preserves result metadata without a Tools service', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const stream = await openFollow(history, session.id, abort.signal) + const stream = await openFollow(web, session.id, abort.signal) const collected = collect(stream, 2, abort) const call = session.append('tool/call', { turn: 1, step: 1, callId: ToolCallId('raw-call'), name: 'custom', arguments: '{malformed', @@ -684,9 +691,9 @@ describe('Session history raw journal', () => { it('follows live results without rescanning Session history', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const stream = await openFollow(history, session.id, abort.signal) + const stream = await openFollow(web, session.id, abort.signal) const iterator = stream[Symbol.asyncIterator]() session.append('tool/call', { @@ -957,9 +964,9 @@ describe('Session history raw journal', () => { it('follows a result after turn/end without reading the addressed Session log', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const stream = await openFollow(history, session.id, abort.signal) + const stream = await openFollow(web, session.id, abort.signal) const iterator = stream[Symbol.asyncIterator]() session.append('turn/start', { turn: 1 }) diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index cda4c14e6a..7c7db7e856 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -329,40 +329,6 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('authorizes attachment bytes only when the session event stream references the id', async () => { - const { ctx, agent, sessionId } = await harness() - const ref = { - attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1, - } - const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) - ctx.provide('attachments', { readImage } as never) - const remote = await createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), - cwd: '/tmp', - }) - agent.session.append('agent/inbox/spliced', { - target: 'next-turn', - start: 0, - inserted: [{ - id: 'queued-image', role: 'user', source: { kind: 'user' }, - content: [{ type: 'image', attachment: ref }], - }], - } as never) - - const allowed = await remote.attachment(request({ - sessionId, attachmentId: 'att-authorized' as never, - })) - expect(allowed).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } }) - const denied = await remote.attachment(request({ - sessionId, attachmentId: 'att-other' as never, - })) - expect(denied).toMatchObject({ - ok: false, - error: { code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, - }) - expect(readImage).toHaveBeenCalledOnce() - await ctx.fiber.dispose() - }) it('checks configured key references even for empty catalogs and skips profiles without keys', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 8d1838e40f..75f81b9d01 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -15,7 +15,7 @@ import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionEventStream } from '../src/client/transport.ts' -import type { SessionFollowRequest, SessionPage, SessionPageRequest } from '../src/types.ts' +import type { SessionFollowRequest, SessionEventsPage, SessionPageRequest } from '../src/types.ts' import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' import { sessionBench } from './remote/bench.client.ts' import { @@ -122,7 +122,7 @@ describe('Session open', () => { it('stitches live frames landing right behind the opening snapshot, dropping the page overlap', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const gate = Promise.withResolvers() + const gate = Promise.withResolvers() const page = plainTurn(SessionSeq(10), 0, '早', '安') mock.stream(FOLLOW, async ([request], stream) => { stream.push(followSnapshot(await gate.promise, request as SessionFollowRequest)) @@ -243,7 +243,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(request => (request.beforeSeq === 12 ? history(middle, true) : history(oldest)))) mock.remote.session.page.mockReturnValueOnce(gate.promise) const jump = session.loadThrough(SessionSeq(0)) @@ -279,7 +279,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() const secondRequested = Promise.withResolvers() - const lastPage = Promise.withResolvers>() + const lastPage = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule((request) => { if (request.beforeSeq === 12) return history(middle, true) secondRequested.resolve(undefined) @@ -334,7 +334,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() const secondRequested = Promise.withResolvers() - const lastPage = Promise.withResolvers>() + const lastPage = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule((request) => { if (request.beforeSeq === 12) return history(plainTurn(SessionSeq(6), 1, 'middle', 'answer'), true) secondRequested.resolve(undefined) @@ -360,7 +360,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'e', 'f'), true))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(history(oldest))) mock.remote.session.page.mockReturnValueOnce(gate.promise) const first = session.loadThrough(SessionSeq(6)) @@ -381,7 +381,7 @@ describe('paging', () => { await session.open() // A plain single-page pull holds the busy flag while the jump is refused. - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockReturnValueOnce(gate.promise) const older = session.loadOlder() await session.loadThrough(SessionSeq(0)) // refused: must not park seq 0 anywhere @@ -409,7 +409,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'x', 'y'), true))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockReturnValueOnce(gate.promise) const jump = session.loadThrough(SessionSeq(0)) // The address is rebuilt while the first page is in flight; the same follow script serves the new generation. @@ -459,7 +459,7 @@ describe('paging', () => { const session = await sessionBench(mock, start, SID) mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'x', 'y'), true))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(gate.promise)) const first = session.loadOlder() const second = session.loadOlder() @@ -484,7 +484,7 @@ describe('prompt and cancel errors', () => { expect(steered).toEqual({ ok: true, value: { accepted: true } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) expect(mock.log.requests(FOLLOW)).toEqual([ - { address: { kind: 'subagent', ...CHILD }, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: { kind: 'subagent', ...CHILD }, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(mock.log.requests(PAGE)).toEqual([]) // The prompt mode crosses the wire as the request's delivery. @@ -593,7 +593,7 @@ describe('prompt and cancel errors', () => { expect(mock.log.requests('subagents/prompt')).toMatchObject([CHILD]) expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) expect(mock.log.requests(FOLLOW)).toEqual([ - { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(mock.log.requests(PAGE)).toEqual([]) expect(mock.log.requests('session/cancel')).toEqual([]) @@ -684,9 +684,11 @@ describe('prompt and cancel errors', () => { expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'gateway/internal' } }) }) - it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async ({ mock, start }) => { + it('sends the complete image reference and decodes the gateway response', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const result = await session.readAttachment('attachment-1' as never) + const attachment = { attachmentId: AttachmentId('attachment-1'), mediaType: 'image/png' as const, + bytes: 1, width: 1, height: 1, name: 'picture.png', originalDimensions: { width: 2, height: 2 } } + const result = await session.readAttachment(attachment) expect(result).toEqual({ ok: true, value: { @@ -695,7 +697,7 @@ describe('prompt and cancel errors', () => { }, }) expect(mock.log.requests('session/attachment')).toEqual([{ - sessionId: SID, attachmentId: 'attachment-1', + sessionId: SID, attachment, }]) }) @@ -703,7 +705,9 @@ describe('prompt and cancel errors', () => { const session = await sessionBench(mock, start, SID) const failure = new RemoteError('gateway/internal', 'attachment unavailable', {}) mock.remote.session.attachment.mockResolvedValueOnce(err(failure)) - await expect(session.readAttachment(AttachmentId('missing'))).resolves.toMatchObject({ ok: false, error: failure }) + await expect(session.readAttachment({ + attachmentId: AttachmentId('missing'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + })).resolves.toMatchObject({ ok: false, error: failure }) }) it('reports an unmatched command and forwards execution failures without opening history', async ({ mock, start }) => { @@ -874,7 +878,7 @@ describe('remaining branches', () => { const session = await sessionBench(mock, start, SID) mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(gate.promise)) await pushEvent(mock, ev.user(SessionSeq(9), '洞一')) await pushEvent(mock, ev.user(SessionSeq(10), '洞二')) @@ -887,7 +891,7 @@ describe('remaining branches', () => { it('drops a stale opening without replacing the new generation error state', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const stale = Promise.withResolvers>() + const stale = Promise.withResolvers>() mock.stream(FOLLOW, followScript(() => stale.promise)) const opening = session.open() mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) @@ -899,7 +903,7 @@ describe('remaining branches', () => { it('drops a stale doOpen whose history resolved successfully after resync superseded it', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const stale = Promise.withResolvers>() + const stale = Promise.withResolvers>() mock.stream(FOLLOW, followScript(() => stale.promise)) const opening = session.open() mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, '新', '代')))) @@ -913,7 +917,7 @@ describe('remaining branches', () => { const session = await sessionBench(mock, start, SID) mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() - const repairPull = Promise.withResolvers>() + const repairPull = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(repairPull.promise)) await pushEvent(mock, ev.user(SessionSeq(9), '洞')) await vi.waitFor(() => { expect(mock.log.requests(PAGE)).toHaveLength(1) }) @@ -955,7 +959,7 @@ describe('resync', () => { mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, '旧', '窗')))) await session.open() const oldWindow = session.eventSource.getSnapshot() - const replacement = Promise.withResolvers() + const replacement = Promise.withResolvers() // The reconnect generation: its snapshot is gated, then two live frames land out of order right behind it. mock.stream(FOLLOW, async ([request], stream) => { stream.push(followSnapshot(await replacement.promise, request as SessionFollowRequest)) @@ -1014,7 +1018,7 @@ describe('resync', () => { it('drops a stale in-flight open superseded by resync (generation guard)', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const stale = Promise.withResolvers>() + const stale = Promise.withResolvers>() mock.stream(FOLLOW, followScript(() => stale.promise)) const firstOpen = session.open() mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, '新', '代')))) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index f43e32e64e..212320fdf6 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -12,7 +12,7 @@ import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/s import { ClientSessions, SessionCreateError, SessionForkError } from '../src/client/sessions/service.ts' import { scopeOf } from '../src/client/scope.ts' import type { - SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, + SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionHistoryRecord, } from '../src/types.ts' import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts' @@ -174,7 +174,7 @@ describe('scope tree', () => { it('opens a conversation from follow projections without a second projection request', async ({ bench }) => { const b = bench() b.mock.stream(FOLLOW, followScript(ok({ - records: [], hasMore: false, + cursor: 0, records: [], hasMore: false, projections: { asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'one-shot', }] } }, @@ -277,7 +277,7 @@ describe('scope tree', () => { it('replaces an active assistant baseline on reconnect without duplicate chunks', async ({ bench }) => { const b = bench() const attemptId = LlmAttemptId('reconnect-attempt') - let records: never[] = [] + let records: SessionHistoryRecord[] = [] let assistantStreamBaseline: SessionAssistantStreamBaseline = { revision: 2, activeAttempt: { @@ -287,7 +287,7 @@ describe('scope tree', () => { }, } b.mock.stream(FOLLOW, followScript( - () => ok({ records, hasMore: false }), + () => ok({ cursor: records.at(-1)?.event.seq ?? -1, records, hasMore: false }), { assistantStream: () => assistantStreamBaseline }, )) await feedList(b, [{ id: 's1' }]) @@ -361,6 +361,7 @@ describe('scope tree', () => { }, } const history = ok({ + cursor: priorMessage.event.seq, records: [priorMessage] as never[], hasMore: false, }) @@ -437,7 +438,7 @@ describe('scope tree', () => { surfaceOp: 'append' as const, }, } - let records = [prior] as never[] + let records: SessionHistoryRecord[] = [prior] let assistantStreamBaseline: SessionAssistantStreamBaseline = { revision: 2, activeAttempt: { @@ -450,7 +451,7 @@ describe('scope tree', () => { }, } b.mock.stream(FOLLOW, followScript( - () => ok({ records, hasMore: false }), + () => ok({ cursor: records.at(-1)?.event.seq ?? -1, records, hasMore: false }), { assistantStream: () => assistantStreamBaseline }, )) await feedList(b, [{ id: 's1' }]) diff --git a/packages/api/session-controller/tests/state-controller.host.spec.ts b/packages/api/session-controller/tests/state-controller.host.spec.ts new file mode 100644 index 0000000000..5fc74006ff --- /dev/null +++ b/packages/api/session-controller/tests/state-controller.host.spec.ts @@ -0,0 +1,480 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import { brandString } from '@deepseek-ai/dsh-brand' +import type AgentRegistry from '@deepseek-ai/dsh-agent' +import type TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import type { PendingRemoteEventInfo } from '@deepseek-ai/dsh-api-gateway' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HistoryController } from '../src/history/controller.ts' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { StateController } from '../src/state/controller.ts' +import { StateListener } from '../src/state/listener.ts' +import type { SessionState, SessionRequestId } from '../src/types.ts' +import { installSessionReadTestServices } from './test-remote.ts' + +const roots: Context[] = [] +const aborters: AbortController[] = [] +const operations: Promise[] = [] +function cancellation(): AbortController { + const abort = new AbortController() + aborters.push(abort) + return abort +} +function tracked(promise: Promise): Promise { + operations.push(promise) + void promise.catch(() => undefined) + return promise +} +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} +afterEach(async () => { + for (const abort of aborters.splice(0)) abort.abort() + await Promise.allSettled(operations.splice(0)) + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + vi.restoreAllMocks() + vi.useRealTimers() +}) + +async function fixture() { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const session = ctx.sessions.create(SessionId('state-target'), { meta: { cwd: '/state-test' } }) + let agent: Agent | undefined + const get = vi.fn(() => agent) + const agents: Partial = { get } + ctx.provide('agents', agents as AgentRegistry) + const pending: PendingRemoteEventInfo[] = [] + const pendingRead = vi.fn(() => pending) + const gateway: Partial = { getPendingRemoteEvents: pendingRead } + ctx.provide('typertGateway', gateway as TypertGatewayService) + const history = new HistoryController(ctx) + const output = vi.spyOn(history, 'assistantOutput').mockResolvedValue({ records: [], truncated: false }) + const controller = new StateController(ctx, history) + const abort = cancellation() + const request = { sessionId: session.id } + const live = (status: Agent['status'] = 'running', idle = Promise.resolve(undefined)) => { + const value = { + id: session.id, session, status, + inbox: createInboxStub(), + whenIdle: vi.fn(() => idle), + } + const partial: Partial = value + agent = partial as Agent + return value + } + const addPending = (event: string, id = session.id) => { + pending.push({ event, agentId: brandString(id) }) + } + return { + ctx, session, controller, history, output, abort, request, live, get, pending, pendingRead, addPending, + setAgent: (value: Agent | undefined) => { agent = value }, + } +} +const emptyState: SessionState = { cursor: -1, active: false, running: false, queue: null, pendingInput: [] } + +describe('StateController', () => { + it('re-reads a state whose cold read turned live', async () => { + const f = await fixture() + const agent = f.live('running') + // The first lookup misses; the Session is a live source by the time the cold read runs. + let lookups = 0 + f.get.mockImplementation(() => (lookups++ === 0 ? undefined : agent as never)) + const state = await f.controller.state(f.request, new AbortController().signal) + expect(state).toMatchObject({ active: true, running: true, queue: [] }) + expect(state.cursor).toBe(f.session.seq - 1) + expect(lookups).toBeGreaterThan(1) + }) + + it('rejects a live state read whose Session records no cwd', async () => { + const f = await fixture() + const session = f.ctx.sessions.create(SessionId('no-cwd-live')) + const partial: Partial = { + id: session.id, session, status: 'idle', inbox: createInboxStub(), whenIdle: vi.fn(() => Promise.resolve(undefined)), + } + f.setAgent(partial as Agent) + await expect(f.controller.state({ sessionId: session.id }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('rejects a cold state read whose stored header records no cwd', async () => { + const f = await fixture() + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: f.session.id, createdAt: 1, isSeeded: false, + } + const observation: SessionObservation = { + source: 'prepared', + header, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: () => observation, + [Symbol.dispose]: () => {}, + } + vi.spyOn(f.history, 'usingObservation').mockImplementation(async (_request, _signal, callback) => + callback?.(observation)) + await expect(f.controller.state(f.request, new AbortController().signal)) + .rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('reads cold state without projections and reports classified, deduplicated pending input', async () => { + const f = await fixture() + const read = vi.spyOn(f.history, 'usingObservation') + f.addPending('approval/request') + f.addPending('user-questions/request') + f.addPending('fixture/request') + f.addPending('approval/request') + f.addPending('ignored', SessionId('other')) + expect(await f.controller.state(f.request, f.abort.signal)).toEqual({ ...emptyState, pendingInput: ['approval', 'question', 'other'] }) + expect(read).toHaveBeenCalledWith({ sessionId: f.session.id, projection: false }, f.abort.signal, expect.any(Function)) + }) + + it('preserves live queues, RPC identities and steering versus context placement', async () => { + const f = await fixture() + const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) + const steering = createUserMessage({ content: [{ type: 'text', text: 'steer' }], source: { kind: 'user', rpcId: 'rpc' as SessionRequestId } }) + const context = createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'user-approval' } }) + const agent = f.live() + agent.inbox.append('next-turn', queued) + agent.inbox.append('next-step', steering) + agent.inbox.append('next-step', context) + expect(await f.controller.state(f.request, f.abort.signal)).toEqual({ + cursor: -1, active: true, running: true, pendingInput: [], + queue: [ + { id: queued.id, placement: 'queued', content: queued.content }, + { id: steering.id, placement: 'steering', content: steering.content, rpcId: 'rpc' }, + { id: context.id, placement: 'context', content: context.content }, + ], + }) + }) + + it('rejects already cancelled state reads', async () => { + const f = await fixture() + f.abort.abort(new Error('cancelled')) + await expect(f.controller.state(f.request, f.abort.signal)).rejects.toThrow('cancelled') + }) + + it('observes complete replacements and closes a pending read on cancellation', async () => { + const f = await fixture() + const states = f.controller.observeState(f.request, f.abort.signal)[Symbol.asyncIterator]() + expect(await states.next()).toMatchObject({ value: emptyState }) + f.live() + const next = tracked(states.next()) + f.session.append('turn/start', { turn: 1 }) + expect(await next).toMatchObject({ value: { active: true, running: true, cursor: 0 } }) + const end = tracked(states.next()) + f.abort.abort() + expect(await end).toEqual({ done: true, value: undefined }) + await states.return?.() + }) + + it('returns inactive idle state and delegates the exact output window with caller signal', async () => { + const f = await fixture() + const result = await f.controller.wait({ ...f.request, afterSeq: 3, maxMessages: 2 }, f.abort.signal) + expect(result).toEqual({ reason: 'idle', state: emptyState, output: { records: [], truncated: false } }) + expect(f.output).toHaveBeenCalledWith(f.session.id, 3, -1, 2, f.abort.signal) + }) + + it('stops immediately for pending input even while the Agent runs', async () => { + const f = await fixture() + f.live() + f.addPending('approval/request') + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'input-required' }) + }) + + it('confirms an active idle Agent with whenIdle', async () => { + const f = await fixture() + const agent = f.live('idle') + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle', state: { active: true } }) + expect(agent.whenIdle).toHaveBeenCalledOnce() + }) + + it('polls pending input after one second without Gateway notifications', async () => { + const f = await fixture() + vi.useFakeTimers() + f.live() + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + await vi.advanceTimersByTimeAsync(0) + expect(f.pendingRead).toHaveBeenCalledTimes(1) + f.addPending('user-questions/request') + await vi.advanceTimersByTimeAsync(999) + expect(f.pendingRead).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(await result).toMatchObject({ reason: 'input-required', state: { pendingInput: ['question'] } }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('uses the latest accepted state on timeout and still reads output with the caller signal', async () => { + const f = await fixture() + vi.useFakeTimers() + f.live() + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 1500 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(1500) + expect(await result).toMatchObject({ reason: 'timeout', state: { running: true } }) + expect(f.output).toHaveBeenCalledWith(f.session.id, -1, -1, 50, f.abort.signal) + expect(f.abort.signal.aborted).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('responds to a standard event before the polling tick and races idle against new state', async () => { + const f = await fixture() + vi.useFakeTimers() + const idle = deferred() + f.live('idle', idle.promise) + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + await vi.advanceTimersByTimeAsync(0) + f.addPending('approval/request') + f.session.append('turn/start', { turn: 1 }) + await vi.advanceTimersByTimeAsync(0) + expect(await result).toMatchObject({ reason: 'input-required', state: { cursor: 0 } }) + idle.resolve(undefined) + expect(vi.getTimerCount()).toBe(0) + }) + + it('propagates cancellation while waiting and cleans periodic reads', async () => { + const f = await fixture() + vi.useFakeTimers() + f.live() + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + await vi.advanceTimersByTimeAsync(0) + f.abort.abort(new Error('caller cancelled')) + await expect(result).rejects.toThrow('caller cancelled') + expect(f.output).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('propagates an idle-driver failure without treating it as a deadline', async () => { + const f = await fixture() + const idle = deferred() + const agent = f.live('idle', idle.promise) + agent.whenIdle.mockImplementation(() => Promise.reject(new Error('driver failed'))) + await expect(f.controller.wait(f.request, f.abort.signal)).rejects.toThrow('driver failed') + idle.resolve(undefined) + }) + + it('propagates a structured read failure raised before the deadline', async () => { + const f = await fixture() + const failure = deferred() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + await failure.promise + throw new RemoteError('gateway/internal', 'persistent read failed', {}) + }) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 5_000 }, f.abort.signal)) + failure.resolve(undefined) + await expect(result).rejects.toMatchObject({ code: 'gateway/internal' }) + }) + + it('keeps a structured read failure raised after the deadline', async () => { + const f = await fixture() + vi.useFakeTimers() + let rejectRead!: (error: Error) => void + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + await new Promise((_resolve, reject) => { rejectRead = reject }) + yield* [] + }) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 20 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(20) + rejectRead(new RemoteError('gateway/internal', 'persistent read failed', {})) + await expect(result).rejects.toMatchObject({ code: 'gateway/internal' }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('reports timeout when its first cold read never yields a state', async () => { + const f = await fixture() + vi.useFakeTimers() + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockImplementation((_id, options) => new Promise((_resolve, reject) => { + const abort = options?.signal + abort?.addEventListener('abort', () => { + const reason: unknown = abort.reason + reject(Object.assign(new Error('session observation was aborted'), { + code: 'SESSION_QUERY_ABORTED', cause: reason, + })) + }, { once: true }) + })) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 20 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(20) + await expect(result).rejects.toMatchObject({ code: 'session/wait-timeout' }) + expect(f.output).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('rejects a state stream that ends before a stopping observation', async () => { + const f = await fixture() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () {}) + await expect(f.controller.wait(f.request, f.abort.signal)).rejects.toMatchObject({ code: 'session/observation-ended' }) + }) + + it('settles an already-armed failed read when the preceding observation stops the wait', async () => { + const f = await fixture() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: emptyState, revision: 0 } + throw new Error('next read failed') + }) + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle' }) + }) + + it('ignores a yielded state invalidated before wait accepts it', async () => { + const f = await fixture() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: { ...emptyState, running: true }, revision: 0 } + yield { state: emptyState, revision: 1 } + }) + vi.spyOn(StateListener.prototype, 'isCurrent').mockReturnValueOnce(false).mockReturnValue(true) + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle', state: emptyState }) + }) + + it('times out with the last accepted state, not a later invalidated sample', async () => { + const f = await fixture() + vi.useFakeTimers() + const accepted = { ...emptyState, running: true } + const stale = { ...emptyState, running: true, cursor: 7 } + const never = deferred() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: accepted, revision: 0 } + yield { state: stale, revision: 0 } + await never.promise + yield* [] + }) + vi.spyOn(StateListener.prototype, 'isCurrent').mockReturnValueOnce(true).mockReturnValue(false) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 20 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(20) + never.resolve(undefined) + await expect(result).resolves.toMatchObject({ reason: 'timeout', state: { cursor: -1 } }) + }) + + it.each(['missing-agent', 'different-session'] as const)('retries active state when its owner is %s', async (condition) => { + const f = await fixture() + const agent = f.live('idle') + if (condition === 'missing-agent') f.setAgent(undefined) + else Object.assign(agent, { session: f.ctx.sessions.create(SessionId('replacement')) }) + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: { ...emptyState, active: true, queue: [] }, revision: 0 } + yield { state: emptyState, revision: 0 } + }) + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle', state: emptyState }) + expect(agent.whenIdle).not.toHaveBeenCalled() + }) + + it.each(['revision', 'agent', 'session'] as const)('rechecks %s after the idle promise settles', async (changed) => { + const f = await fixture() + const idle = deferred() + const entered = deferred() + const next = deferred() + const agent = f.live('idle', idle.promise) + agent.whenIdle.mockImplementation(() => { entered.resolve(undefined); return idle.promise }) + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: { ...emptyState, active: true, queue: [] }, revision: 0 } + await next.promise + yield { state: emptyState, revision: 0 } + }) + const rechecked = deferred() + let checks = 0 + vi.spyOn(StateListener.prototype, 'isCurrent').mockImplementation(() => { + checks++ + if (checks === 2) rechecked.resolve(undefined) + return !(changed === 'revision' && checks === 2) + }) + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + try { + await entered.promise + if (changed === 'agent') f.setAgent(undefined) + if (changed === 'session') Object.assign(agent, { session: f.ctx.sessions.create(SessionId('new-session')) }) + idle.resolve(undefined) + await rechecked.promise + next.resolve(undefined) + expect(await result).toMatchObject({ reason: 'idle', state: emptyState }) + } finally { + idle.resolve(undefined) + next.resolve(undefined) + f.abort.abort() + await Promise.allSettled([result]) + } + }) + + it.each([ + { afterSeq: -2 }, { afterSeq: -0 }, { afterSeq: 1.5 }, + { maxMessages: 0 }, { maxMessages: 1.5 }, + { timeoutMs: 0 }, { timeoutMs: 2147483648 }, { timeoutMs: 1.5 }, + ])('rejects unsupported wait bounds %j', async (bounds) => { + const f = await fixture() + await expect(f.controller.wait({ ...f.request, ...bounds }, f.abort.signal)).rejects.toMatchObject({ code: 'gateway/bad-request' }) + }) +}) + +describe('StateListener', () => { + it('discards an async stale read and coalesces repeated invalidations', async () => { + const f = await fixture() + const first = deferred() + const reading = deferred() + const read = vi.fn() + .mockImplementationOnce(() => { reading.resolve(undefined); return first.promise }) + .mockResolvedValue({ ...emptyState, cursor: 1 }) + using listener = new StateListener(f.ctx, f.session.id, f.abort.signal, read) + const states = listener.observeStates() + const pending = tracked(states.next()) + await reading.promise + f.session.append('turn/start', { turn: 1 }) + f.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + first.resolve(emptyState) + expect(await pending).toEqual({ done: false, value: { state: { ...emptyState, cursor: 1 }, revision: 2 } }) + expect(read).toHaveBeenCalledTimes(2) + expect(listener.isCurrent(0)).toBe(false) + expect(listener.isCurrent(2)).toBe(true) + await states.return(undefined) + expect(listener.isCurrent(2)).toBe(false) + }) + + it('ignores other Sessions and ends when its Context is disposed', async () => { + const f = await fixture() + const read = vi.fn(async () => emptyState) + const listener = new StateListener(f.ctx, f.session.id, f.abort.signal, read) + const states = listener.observeStates() + await states.next() + const pending = tracked(states.next()) + f.ctx.sessions.create(SessionId('unrelated')) + expect(read).toHaveBeenCalledOnce() + await f.ctx.fiber.dispose() + expect(await pending).toEqual({ done: true, value: undefined }) + listener[Symbol.dispose]() + }) + + it('invalidates for each Agent lifecycle notification', async () => { + const f = await fixture() + f.live('idle') + const agent = f.get()! + using listener = new StateListener(f.ctx, f.session.id, f.abort.signal, async () => emptyState) + const states = listener.observeStates() + await states.next() + for (const emit of [ + () => { f.ctx.emit('agent/status', { agent, status: 'running' }) }, + () => { f.ctx.emit('agent/created', { agent, source: 'startup' }) }, + () => { f.ctx.emit('agent/disposed', { agent }) }, + ]) { + const pending = tracked(states.next()) + emit() + expect(await pending).toMatchObject({ done: false, value: { state: emptyState } }) + } + await states.return(undefined) + }) + + it('rejects cancelled construction and contains setup failure by removing abort listeners', async () => { + const f = await fixture() + f.abort.abort() + expect(() => new StateListener(f.ctx, f.session.id, f.abort.signal, async () => emptyState)).toThrow() + const abort = cancellation() + const remove = vi.spyOn(abort.signal, 'removeEventListener') + vi.spyOn(f.ctx, 'on').mockImplementation(() => { throw new Error('registration failed') }) + expect(() => new StateListener(f.ctx, f.session.id, abort.signal, async () => emptyState)).toThrow('registration failed') + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + }) +}) diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts index 9534b8b66d..36216a1f1c 100644 --- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts +++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts @@ -105,7 +105,7 @@ describe('SessionController subagent catalog', () => { kind: 'sequenced', values: { subagentCatalog: [{ id: CHILD, mode: 'continuable', label: 'worker' }] }, }) expect(stat).toHaveBeenCalledWith(PARENT, { signal }) - expect(observe).toHaveBeenCalledWith(PARENT, { signal }) + expect(observe).toHaveBeenCalledWith(PARENT, { signal, projectionMode: 'all' }) expect(open).toHaveBeenCalledWith(PARENT, 'read', { signal }) expect(resume).not.toHaveBeenCalled() expect(ctx.sessions.get(PARENT)).toBeUndefined() diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 114e15734d..145b48f5c3 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -51,7 +51,7 @@ import type { SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionWorkspacePathApplication, - SessionPage, + SessionEventsPage, SessionPageRequest, SessionPromptRequest, SessionPromptValue, @@ -66,7 +66,7 @@ import type { SessionUpdateQueueValue, } from '../src/types.ts' -/** Direct test face matching the generated `ctx.remote.session` unary methods. */ +/** Direct test face for the generated `ctx.remote.session` unary methods these tests exercise. */ export interface TestSessionRemote { workspacePathApplications( request: { readonly path: string }, signal?: AbortSignal, @@ -87,7 +87,7 @@ export interface TestSessionRemote { request: SessionOpenWorkspacePathRequest, signal?: AbortSignal, ): Promise> - page(request: SessionPageRequest, signal?: AbortSignal): Promise> + page(request: SessionPageRequest, signal?: AbortSignal): Promise> projections(request: SessionProjectionsRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable @@ -388,6 +388,7 @@ export async function createSessionTestRemote( signal, ), page: (request, signal = new AbortController().signal) => remoteResult( + // oxlint-disable-next-line typescript/no-deprecated -- Test face keeps the legacy page entry covered. () => direct.page(request, signal), signal, ), diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 87d8fd7479..2c60701f80 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -25,7 +25,7 @@ import type { SessionFollowFrame, SessionFollowRequest, SessionHistoryRecord, - SessionPage, + SessionEventsPage, SessionPageRequest, SessionWireEvent, } from '../src/types.ts' @@ -44,8 +44,8 @@ function entry(seq: number): SessionEventEntry { return { type: 'event', event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } } } -function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionPage { - return { records, hasMore } +function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionEventsPage { + return { cursor: records.at(-1)?.event?.seq ?? -1, records, hasMore } } function snapshot( @@ -102,7 +102,7 @@ class ScriptedSessionRemote implements SessionTransportRemote { constructor( private readonly generations: FollowGeneration[], - private readonly pages: RemoteResult[], + private readonly pages: RemoteResult[], private readonly controlFrames: readonly SessionControlFrame[] = [], private readonly holdControl = true, ) {} @@ -126,7 +126,7 @@ class ScriptedSessionRemote implements SessionTransportRemote { } } - page(request: SessionPageRequest): Promise> { + page(request: SessionPageRequest): Promise> { this.pageRequests.push(request) const result = this.pages.shift() if (result === undefined) throw new Error('no scripted Session page') @@ -326,7 +326,7 @@ describe('Session Client stream adapters', () => { await stream.open({}) await vi.waitFor(() => { expect(changes).toHaveLength(2) }) - expect(remote.followRequests).toEqual([{ address: ADDRESS, assistantStream: true }]) + expect(remote.followRequests).toEqual([{ address: ADDRESS, activate: true, assistantStream: true }]) expect(changes).toMatchObject([ { type: 'replace', page: { assistantStream: baseline } }, { type: 'assistant-stream', frame }, @@ -382,7 +382,7 @@ describe('Session Client stream adapters', () => { code: 'gateway/internal', message: 'session event stream emitted an entry before its opening cursor', }) - expect(remote.followRequests).toEqual([{ address: ADDRESS, assistantStream: true }]) + expect(remote.followRequests).toEqual([{ address: ADDRESS, activate: true, assistantStream: true }]) } finally { await stream.dispose() } @@ -547,7 +547,7 @@ describe('Session Client stream adapters', () => { await stream.prepend({ beforeSeq: 2, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }) expect(remote.followRequests).toEqual([{ - address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 }, + address: ADDRESS, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 }, }]) expect(remote.pageRequests).toEqual([ { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, @@ -585,8 +585,8 @@ describe('Session Client stream adapters', () => { await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) expect(remote.followRequests).toEqual([ - { address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, - { address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: ADDRESS, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: ADDRESS, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(remote.pageRequests).toEqual([]) expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) @@ -616,8 +616,8 @@ describe('Session Client stream adapters', () => { finish.resolve(undefined) await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) expect(remote.followRequests).toEqual([ - { address: ADDRESS, assistantStream: true }, - { address: ADDRESS, assistantStream: true }, + { address: ADDRESS, activate: true, assistantStream: true }, + { address: ADDRESS, activate: true, assistantStream: true }, ]) expect(remote.pageRequests).toEqual([]) await stream.dispose() diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index e48b114c91..8a3df2ae43 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -5,8 +5,9 @@ import type { Session, SessionEvent, SessionHeader, SurfaceIntent } from '@deeps import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' -import { describe, expect, it, vi } from 'vitest' -import { SessionHistoryController } from '../src/history.ts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HistoryController } from '../src/history/controller.ts' +import { WebController } from '../src/web/controller.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' const signal = (): AbortSignal => new AbortController().signal @@ -72,22 +73,39 @@ function deferred(): Deferred { return { promise, resolve } } -async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> { +const roots = new Set() +afterEach(async () => { + await Promise.all([...roots].map(ctx => ctx.fiber.dispose())) + roots.clear() +}) + +function controllers( + ctx: Context, promote: (observation: SessionObservation) => void = (observation) => { observation[Symbol.dispose]() }, +): { + history: HistoryController + web: WebController +} { + roots.add(ctx.root) + installSessionReadTestServices(ctx) + const history = new HistoryController(ctx) + return { history, web: new WebController(ctx, history, promote) } +} + +async function setup(): Promise<{ ctx: Context; history: HistoryController; web: WebController }> { const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) - const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) - return { ctx, transport } + return { ctx, ...controllers(ctx) } } -describe('SessionHistoryController', () => { +describe('Session history and Web follow', () => { it('opens at the current cursor and follows later events from an ordinary Session', async () => { - const { ctx, transport } = await setup() + const { ctx, history, web } = await setup() const session = ctx.sessions.create(SessionId('ordinary'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const abort = new AbortController() - const iterator = transport.follow( + const iterator = web.follow( { address: { kind: 'session', sessionId: session.id } }, abort.signal, )[Symbol.asyncIterator]() @@ -99,7 +117,7 @@ describe('SessionHistoryController', () => { value: { type: 'event', event: { type: 'turn/end', seq: 1 } }, }) - const page = await transport.page( + const page = await history.events( { address: { kind: 'session', sessionId: session.id }, throughSeq: 1 }, new AbortController().signal, ) @@ -113,16 +131,16 @@ describe('SessionHistoryController', () => { const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) - let transport!: SessionHistoryController + let web!: WebController const owner = ctx.plugin(Object.assign( (inner: Context) => { - transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() }) + web = controllers(inner).web }, { inject: ['sessions', 'sessionQuery'] }, )) await owner.await() const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } }) - const iterator = transport.follow( + const iterator = web.follow( { address: { kind: 'session', sessionId: session.id } }, new AbortController().signal, )[Symbol.asyncIterator]() @@ -138,13 +156,13 @@ describe('SessionHistoryController', () => { }) it('reconnects with a complete replacement snapshot before later live events', async () => { - const { ctx, transport } = await setup() + const { ctx, web } = await setup() const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/start', { turn: 2 }) const abort = new AbortController() - const iterator = transport.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, }, abort.signal)[Symbol.asyncIterator]() @@ -168,7 +186,7 @@ describe('SessionHistoryController', () => { }) it('subscribes before a cold read and ignores unrelated and replayed buffered events', async () => { - const { ctx, transport } = await setup() + const { ctx, web } = await setup() const sessionId = SessionId('cold-race') const header: SessionHeader = { version: SESSION_FORMAT_VERSION, @@ -187,7 +205,7 @@ describe('SessionHistoryController', () => { inspect: () => inspected.promise, }) as never) const abort = new AbortController() - const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() const opening = iterator.next() @@ -220,9 +238,9 @@ describe('SessionHistoryController', () => { } const observed = deferred() ctx.provide('sessionQuery', { observeSession: () => observed.promise } as never) - const transport = new SessionHistoryController(ctx, vi.fn()) + const { web } = controllers(ctx, vi.fn()) const abort = new AbortController() - const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() const opening = iterator.next() @@ -256,11 +274,11 @@ describe('SessionHistoryController', () => { const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) - let transport!: SessionHistoryController + let web!: WebController let agentCtx!: Context await ctx.plugin(Object.assign( (inner: Context) => { - transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() }) + web = controllers(inner).web }, { inject: ['sessions', 'sessionQuery'] }, )) @@ -279,7 +297,7 @@ describe('SessionHistoryController', () => { const seed = [event('fixture/start', SessionSeq(0))] cold(ctx, header, seed) const abort = new AbortController() - const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) @@ -322,7 +340,7 @@ describe('SessionHistoryController', () => { isSeeded: false, } cold(replay.ctx, replayHeader, [event('fixture/start', SessionSeq(0)), event('fixture/gap', SessionSeq(2))]) - const replayed = replay.transport.follow({ + const replayed = replay.web.follow({ address: { kind: 'session', sessionId: replayId }, }, signal())[Symbol.asyncIterator]() await expect(replayed.next()).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) @@ -331,7 +349,7 @@ describe('SessionHistoryController', () => { const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } }) append(session, 'fixture/start', {}) live.ctx.provide('agents', { get: () => ({ id: session.id }) } as never) - const followed = live.transport.follow({ + const followed = live.web.follow({ address: { kind: 'session', sessionId: session.id }, }, signal())[Symbol.asyncIterator]() await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) @@ -345,21 +363,21 @@ describe('SessionHistoryController', () => { }) it('opens an empty source at cursor -1', async () => { - const { ctx, transport } = await setup() + const { ctx, history, web } = await setup() const session = ctx.sessions.create(SessionId('empty-follow'), { meta: { cwd: '/workspace' } }) const abort = new AbortController() - const iterator = transport.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, }, abort.signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: -1, }, signal())).resolves.toMatchObject({ records: [], hasMore: false }) abort.abort() await expect(iterator.next()).resolves.toMatchObject({ done: true }) }) - it('publishes an empty projection baseline when the query has no registry', async () => { + it('fails explicitly when a projected observation omits its baseline', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const sessionId = SessionId('projectionless-follow') @@ -380,19 +398,83 @@ describe('SessionHistoryController', () => { retain: vi.fn(), [Symbol.dispose]: vi.fn(), } satisfies SessionObservation), } as never) - const history = new SessionHistoryController(ctx, vi.fn()) + const { web } = controllers(ctx, vi.fn()) const abort = new AbortController() - const iterator = history.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ - value: { type: 'snapshot', projections: { asOfSeq: -1, values: {} } }, - }) + await expect(iterator.next()).rejects.toMatchObject({ code: 'session/projections-unavailable' }) abort.abort() await expect(iterator.next()).resolves.toMatchObject({ done: true }) await ctx.fiber.dispose() }) + it('does not activate a prepared Session when the request omits activate', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('passive-activation-default') + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false, + } + const disposeSource = vi.fn() + const source: SessionObservation = { + source: 'prepared', + header, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: () => source, + [Symbol.dispose]: disposeSource, + } + ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(source) } as never) + const promote = vi.fn() + const { web } = controllers(ctx, promote) + const abort = new AbortController() + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + expect(promote).not.toHaveBeenCalled() + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + expect(disposeSource).toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('starts activation for a consumer that stops after the opening snapshot', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('activation-after-opening') + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false, + } + const disposed: string[] = [] + const promotion: SessionObservation = { + source: 'prepared', + header, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: vi.fn(), + [Symbol.dispose]: () => { disposed.push('promotion') }, + } + const source: SessionObservation = { + ...promotion, + retain: () => promotion, + [Symbol.dispose]: () => { disposed.push('source') }, + } + ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(source) } as never) + const promote = vi.fn((observation: SessionObservation) => { observation[Symbol.dispose]() }) + const { web } = controllers(ctx, promote) + const iterator = web.follow({ address: { kind: 'session', sessionId }, activate: true }, signal()) + [Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + await iterator.return?.(undefined) + expect(promote).toHaveBeenCalledOnce() + expect(disposed).toContain('promotion') + await ctx.fiber.dispose() + }) + it('disposes a retained promotion when background activation rejects synchronously', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -412,18 +494,17 @@ describe('SessionHistoryController', () => { ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(source), } as never) - const history = new SessionHistoryController(ctx, () => { throw new Error('activation failed') }) - const iterator = history.follow({ address: { kind: 'session', sessionId } }, signal()) + const { web } = controllers(ctx, () => { throw new Error('activation failed') }) + const iterator = web.follow({ address: { kind: 'session', sessionId }, activate: true }, signal()) [Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) await expect(iterator.next()).rejects.toThrow('activation failed') expect(disposePromotion).toHaveBeenCalledOnce() await ctx.fiber.dispose() }) it('requires the durable parent and mode for a direct subagent address', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const parentSessionId = SessionId('parent') const childSessionId = SessionId('child') ctx.sessions.create(parentSessionId, { meta: { cwd: '/workspace' } }) @@ -437,7 +518,7 @@ describe('SessionHistoryController', () => { })) const signal = new AbortController().signal - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, @@ -448,17 +529,17 @@ describe('SessionHistoryController', () => { }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'unknown' }, throughSeq: 0, }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), childSessionId, mode: 'unknown' }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), @@ -467,7 +548,7 @@ describe('SessionHistoryController', () => { }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, @@ -476,14 +557,14 @@ describe('SessionHistoryController', () => { }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'session', sessionId: childSessionId }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'session/agent-busy' }) }) it('preserves a cold inspection failure for the Gateway error branch', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const sessionId = SessionId('corrupt-cold') const failure = new Error('cold log is corrupt') const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, isSeeded: false, cwd: '/workspace' } @@ -492,7 +573,7 @@ describe('SessionHistoryController', () => { inspect: () => Promise.reject(failure), }) as never) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'session', sessionId }, throughSeq: -1, }, new AbortController().signal)).rejects.toMatchObject({ @@ -502,7 +583,7 @@ describe('SessionHistoryController', () => { }) it('rejects malformed page and follow cursors at the service boundary', async () => { - const { ctx, transport } = await setup() + const { ctx, history, web } = await setup() const session = ctx.sessions.create(SessionId('validation'), { meta: { cwd: '/workspace' } }) const address = { kind: 'session' as const, sessionId: session.id } for (const request of [ @@ -521,9 +602,9 @@ describe('SessionHistoryController', () => { { address, throughSeq: -1, turnWindow: { minMessages: 50, minTurns: 0 } }, { address, throughSeq: -1, turnWindow: { minMessages: 50, minTurns: 1.5 } }, ]) { - await expect(transport.page(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' }) + await expect(history.events(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' }) } - await expect(transport.page({ address, throughSeq: 0 }, signal())) + await expect(history.events({ address, throughSeq: 0 }, signal())) .rejects.toMatchObject({ code: 'gateway/bad-request' }) const corrupt = await setup() @@ -533,11 +614,11 @@ describe('SessionHistoryController', () => { { version: SESSION_FORMAT_VERSION, id: corruptId, createdAt: 1, cwd: '/workspace', isSeeded: false }, [event('fixture/start', SessionSeq(0)), event('fixture/gap', SessionSeq(2))], ) - await expect(corrupt.transport.page({ + await expect(corrupt.history.events({ address: { kind: 'session', sessionId: corruptId }, throughSeq: 1, }, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) for (const maxMessages of [0, 0.5]) { - const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() + const iterator = web.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } for (const turnWindow of [ @@ -547,15 +628,15 @@ describe('SessionHistoryController', () => { { minMessages: 50, minTurns: 0 }, { minMessages: 50, minTurns: 1.5 }, ]) { - const iterator = transport.follow({ address, turnWindow }, signal())[Symbol.asyncIterator]() + const iterator = web.follow({ address, turnWindow }, signal())[Symbol.asyncIterator]() await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } }) it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') } - await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) + await expect(history.events({ address: ordinary, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) const inspect = vi.fn(() => Promise.resolve(undefined)) @@ -565,9 +646,9 @@ describe('SessionHistoryController', () => { stat, inspect, }) as never) - await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) + await expect(history.events({ address: ordinary, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId: SessionId('parent'), @@ -594,7 +675,7 @@ describe('SessionHistoryController', () => { events: [], }), }) as never) - await expect(first.transport.page({ address, throughSeq: -1 }, signal())) + await expect(first.history.events({ address, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) const second = await setup() @@ -608,7 +689,7 @@ describe('SessionHistoryController', () => { events: [], }), }) as never) - await expect(second.transport.page({ address, throughSeq: -1 }, signal())) + await expect(second.history.events({ address, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) }) @@ -623,7 +704,7 @@ describe('SessionHistoryController', () => { isSeeded: false, } cold(ordinaryBench.ctx, ordinaryHeader, [event('turn/start', SessionSeq(0), { turn: 1 })]) - await expect(ordinaryBench.transport.page({ + await expect(ordinaryBench.history.events({ address: { kind: 'session', sessionId: ordinaryId }, throughSeq: 0, }, signal())).resolves.toMatchObject({ @@ -649,18 +730,18 @@ describe('SessionHistoryController', () => { } const missing = await setup() cold(missing.ctx, childHeader, []) - await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal())) + await expect(missing.history.events({ address: childAddress, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const corrupt = await setup() cold(corrupt.ctx, childHeader, [event('subagent/descriptor', SessionSeq(0), { version: 'bad' })]) - await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal())) + await expect(corrupt.history.events({ address: childAddress, throughSeq: 0 }, signal())) .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const ordinaryChild = await setup() const { origin: _origin, ...ordinaryChildHeader } = childHeader cold(ordinaryChild.ctx, ordinaryChildHeader, []) - await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal())) + await expect(ordinaryChild.history.events({ address: childAddress, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'subagent/unauthorized' }) }) @@ -689,9 +770,9 @@ describe('SessionHistoryController', () => { retain: vi.fn(), [Symbol.dispose]: vi.fn(), } as unknown as SessionObservation), } as never) - const history = new SessionHistoryController(ctx, vi.fn()) + const { history } = controllers(ctx, vi.fn()) - await expect(history.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, @@ -710,7 +791,7 @@ describe('SessionHistoryController', () => { const session = ordinary.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const ordinarySnapshot = vi.spyOn(ordinary.ctx.sessionProjections, 'snapshot') - const ordinaryPage = await ordinary.transport.page({ + const ordinaryPage = await ordinary.history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: 0, }, signal()) @@ -727,7 +808,7 @@ describe('SessionHistoryController', () => { mode: 'continuable', provider: 'test', label: 'child', })) const childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot') - const page = await child.transport.page({ + const page = await child.history.events({ address: { kind: 'subagent', parentSessionId, @@ -741,7 +822,7 @@ describe('SessionHistoryController', () => { }) it('keeps message-aligned pagination contiguous across replacement source-event references', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -754,27 +835,27 @@ describe('SessionHistoryController', () => { sourceEventSeqs: [SessionSeq(1), firstReply.seq, SessionSeq(3), SessionSeq(4), summary.seq], }) - const page = await transport.page({ + const page = await history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, maxMessages: 2, }, signal()) expect(page.records.map(entry => entry.event.seq)) .toEqual([3, 4, 5, replacement.seq]) expect(page.hasMore).toBe(true) - const before = await transport.page({ + const before = await history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, beforeSeq: 3, maxMessages: 1, }, signal()) expect(before.records.map(entry => entry.event.seq)).toEqual([2]) }) it('keeps cited source events in the page that owns their appended message', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const session = ctx.sessions.create(SessionId('pagination-sources'), { meta: { cwd: '/workspace' } }) const source = append(session, 'fixture/source', {}) append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, { surfaceOp: 'append', sourceEventSeqs: [source.seq], }) - const page = await transport.page({ + const page = await history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: 1, maxMessages: 1, }, signal()) expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1]) diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 8a5fa75166..acadf82099 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -16,12 +16,24 @@ "src/control.ts", "src/file-references.ts", "src/history.ts", + "src/history/controller.ts", + "src/history/listener.ts", + "src/history/assistant-stream.ts", + "src/state/controller.ts", + "src/state/listener.ts", + "src/web/controller.ts", "src/list.ts", "src/media-references.ts", "src/archived-session-gate.ts", "src/skill-catalog.ts" ], "references": [ + { + "path": "../gateway/tsconfig.host.json" + }, + { + "path": "../../util/timeout" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index be08b2fdbd..2caf5e3785 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -36,8 +36,8 @@ en: dcd9857653645420 zh: a27259a09f17465d /deepseek-ai-dsh-attachment-local/understand-the-implementation/write-and-read-paths: - en: 62ea104a00a7fdb8 - zh: b08575274ca244b4 + en: 4cbbe8a1466ed46f + zh: 49d2d118501867d3 /deepseek-ai-dsh-attachment-local/understand-the-implementation/source-map: en: 05af85aa85934d01 zh: 1c89e80d496dc0a3 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 4844be3670..b27ac0c75a 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -83,6 +83,8 @@ This section explains the durability and verification design behind the storage, Objects land at `/attachments/v1/objects//`; equal bytes deduplicate to one object and one `sha256:` id. Before the first write, the process syncs every ancestor directory of the home down to the filesystem root once, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then stage bytes in `v1/tmp`, sync the temporary file, publish with an atomic exclusive hard link, and sync the publication directories — on Windows, filesystem metadata journaling owns entry durability. Once the save resolves, the reported reference is durable. +Image reads receive a complete reference with a canonical `sha256:` ID, derive the object path within this storage tree, verify the complete digest, and probe the image header. The actual format, byte length, width, and height must match the supplied reference. The returned reference preserves optional `name` and `originalDimensions` as caller-supplied display information; these fields are not verified against the object. Reads neither consult Session history nor reapply admission limits; a missing object or failed verification rejects the read. + Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 50 MiB, 64 million pixels, and 65,535 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. Request versions live below `/cache/attachments/request-images/`, resolved by `dshCachePath`; an explicit `dshHome` setting applies to both cache and durable storage. Clearing this cache between requests preserves durable attachments, and later reads regenerate the variants. `readImageRequest` scales without enlargement to the route-chosen target, resizing by the long edge only so the encoder derives the short edge as the route predicts, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, target dimensions, byte target, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index a079785984..a0663aaebe 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -83,6 +83,8 @@ kind: "package-reference" 对象存放在 `/attachments/v1/objects//`;相同字节会去重为同一个对象和同一个 `sha256:` 标识符。首次写入前,进程会把 home 的每个祖先目录逐级同步到文件系统根目录,因此绝不会把另一个进程已创建但尚未同步的目录误认为安全边界。随后,写入过程把字节暂存到 `v1/tmp`、同步临时文件、以原子且排他的硬链接发布,并同步发布目录——在 Windows 上,文件系统元数据日志负责目录项持久性。保存操作完成后,返回的引用已具备持久性。 +图片读取接收包含规范 `sha256:` ID 的完整引用,在该存储目录内推导对象路径,校验完整摘要,并探测图片文件头。实际格式、字节数、宽度和高度必须与传入引用一致。返回的引用把可选的 `name` 和 `originalDimensions` 作为调用方提交的显示信息原样保留,不根据对象校验这两个字段。读取不查询 Session 历史,也不重新应用准入限制;对象缺失或校验失败都会拒绝读取。 + 准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 50 MiB、6400 万像素与单边 65,535 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。 请求版本位于由 `dshCachePath` 解析的 `/cache/attachments/request-images/`;显式 `dshHome` 设置同时适用于缓存与持久存储。在两次请求之间清空此缓存会保留持久附件,后续读取会重新生成请求版本。`readImageRequest` 在不放大的前提下缩放到路由选定的目标尺寸,缩放只按长边给定,短边由编码器按路由预测的方式推出,随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、目标尺寸、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 7fab679e34..88ab2c9d27 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -423,7 +423,7 @@ export async function saveImageFile( /** * Read and verify one content-addressed image. * @param root - absolute `DSH_HOME/attachments/v1` root. - * @param ref - reference recorded in the session log. + * @param ref - supplied image reference; display fields are returned unchanged. * @param signal - optional cancellation for filesystem and verification work. * @returns verified bytes and reference. * @throws the signal reason when aborted, or an AttachmentError when verification fails. diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 82c2b9f3ba..6e7ff4b011 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -148,6 +148,21 @@ describe('local attachment store', () => { await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) + it.each([ + { mediaType: 'image/jpeg' }, { bytes: PNG.byteLength + 1 }, { width: 2 }, { height: 2 }, + ] as const)('rejects supplied metadata that differs from stored bytes: %j', async (mismatch) => { + const storageRoot = await root() + const saved = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + await expect(readImageFile(storageRoot, { ...saved, ...mismatch })).rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('preserves supplied display fields while verifying stored image facts', async () => { + const storageRoot = await root() + const saved = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const supplied = { ...saved, name: 'display.png', originalDimensions: { width: 8, height: 8 } } + await expect(readImageFile(storageRoot, supplied)).resolves.toEqual({ ref: supplied, data: PNG }) + }) + it('rejects publication when the supplied digest does not match the staged bytes', async () => { const storageRoot = await root() const target = join(storageRoot, 'objects', '00', 'mismatch') diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index b2b7ba57ad..125d96125d 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -27,8 +27,8 @@ en: 8a0fa64e84aa4450 zh: ed2ac44ef20250d4 /deepseek-ai-dsh-attachment/use-this-package/reuse-images-across-the-session: - en: c99e4e088be92c4f - zh: 34997ecb9a420d08 + en: ede9266988e20f19 + zh: 6264c601ec62975d /deepseek-ai-dsh-attachment/use-this-package/what-can-go-wrong: en: b35e2f7f17269ae1 zh: 57cf0ef9fee6c7e1 @@ -36,8 +36,8 @@ en: 25d9c7d6c0899d23 zh: ad8380d14810ff1c /deepseek-ai-dsh-attachment/understand-the-implementation/design-decisions: - en: af7deef72fd2fa93 - zh: f217d52ce3971135 + en: 6315b2d1fb12f0ca + zh: c3f99f4c0850fb44 /deepseek-ai-dsh-attachment/understand-the-implementation/service-operations: en: b94d13fe133d8ce6 zh: dd4064d20d510afc diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 2acdc13c42..0e0c2bc05f 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -45,7 +45,7 @@ Commands declaring attachment input receive images and generic files in selectio ### Reuse images across the session -Saved normalized images stay in conversation history and are projected into deterministic, route-sized request versions in later turns; after a restart, a resumed session shows and reuses the same images. When the current execution filesystem maps the stored host object, the request descriptor also carries a read-only process path that the model can inspect. When history or a request version is read back, the stored bytes are checked against what was recorded, so a missing, corrupted, or swapped image surfaces as an error rather than wrong bytes. +Saved normalized images stay in conversation history and are projected into deterministic, route-sized request versions in later turns; after a restart, a resumed session shows and reuses the same images. When the current execution filesystem maps the stored host object, the request descriptor also carries a read-only process path that the model can inspect. History image reads and request projections verify the stored object against the full image reference, including its digest, format, byte length, and dimensions. Missing, corrupted, or swapped images fail explicitly. ### What can go wrong @@ -65,7 +65,7 @@ This section explains the design decisions behind the seam and the service opera - **Normalize and persist before event.** Every source is prepared and verified before the batch publishes in order, so the session log never references a partial or failed normalization. - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. -- **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed. +- **Verify on read.** Reads verify the immutable object against the supplied reference without consulting a Session. Optional display names and pre-normalization dimensions pass through as caller-supplied display information; the stored bytes do not verify these fields. - **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions at an explicit route-chosen target size and byte target, while execution filesystems may map the immutable host object to a model-readable process path. - **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain. - **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 06cbe5631a..9c9d2c7fb5 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" ### 在整个会话中复用图片 -已保存的规范化图片会保留在对话历史中,并在后续轮次投影为确定性的路由尺寸请求版本;重启后,恢复的会话会显示并复用相同的图片。当前执行文件系统可以映射已存宿主对象时,请求描述符还会携带模型可检查的只读进程路径。回读历史或请求版本时,已存储的字节会与记录的内容比对,因此缺失、损坏或被替换的图片会以错误形式呈现,而不是错误的字节。 +已保存的规范化图片会保留在对话历史中,并在后续轮次投影为确定性的路由尺寸请求版本;重启后,恢复的会话会显示并复用相同的图片。当前执行文件系统可以映射已存宿主对象时,请求描述符还会携带模型可检查的只读进程路径。历史图片读取和请求投影会把存储对象与完整图片引用中的摘要、格式、字节数和尺寸比对。图片缺失、损坏或被替换时会明确失败。 ### 可能出什么问题 @@ -65,7 +65,7 @@ kind: "package-reference" - **事件前完成规范化与持久化。** 每个源图都会在批次按序发布前完成准备与校验,因此会话日志绝不会引用部分完成或规范化失败的对象。 - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 -- **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象不会通过校验。 +- **读取时校验。** 读取会把不可变对象与传入引用比对,无需查询 Session。可选的显示名称和规范化前尺寸作为调用方提交的显示信息原样返回;存储字节不校验这两个字段。 - **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按路由显式选定的目标尺寸与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 - **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index ba7f567884..802df0cb6b 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -186,8 +186,8 @@ export abstract class AttachmentStore extends Service { abstract saveImage(input: SaveImageAttachment): Promise /** - * Read one image and verify that bytes still match the recorded reference. - * @param ref - durable reference from the session log. + * Read one image and verify its ID, format, byte length, and dimensions against the supplied reference. + * @param ref - supplied image reference; name and originalDimensions are preserved without file verification. * @param signal - optional cancellation for backend read and verification work. * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. @@ -240,7 +240,7 @@ export abstract class AttachmentStore extends Service { * Read and verify one verbatim stored file as bounded chunks. Providers must * not collect the complete file in memory. Backends without verbatim file * reads keep this default rejection. - * @param ref - durable reference from the session log. + * @param ref - durable file reference from the session log. * @param signal - optional cancellation for backend reads and verification work. * @returns exact file bytes in order; integrity failures reject the iteration. */ diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 0fe0cc80c5..9ce0beae8e 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -24,8 +24,8 @@ en: 65bf2834b81f87da zh: c864ac60a3005b99 /deepseek-ai-dsh-app-boot/use-this-package/profiles: - en: 284f006f3304a32e - zh: b52ccef70e00114c + en: a9c82cd220ae8d59 + zh: 8c7c1c97efcdfc25 /deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration: en: 646a0ccdd7881ad1 zh: e3696acb1573dd7b @@ -42,8 +42,8 @@ en: f4a2695cb4c8a570 zh: 89837235d2f376cf /deepseek-ai-dsh-app-boot/understand-the-implementation/design-notes: - en: 9ecb744556705fd1 - zh: d83aaf2ebb91d0fc + en: 6eba01e24a2a68ab + zh: 64af424489087ec7 /deepseek-ai-dsh-app-boot/understand-the-implementation/helper-behavior: en: 6fa850d1bb000d0e zh: aa83a40a9d3db295 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 4f0793c034..4d30457f81 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -53,7 +53,7 @@ Before a profile imports a plugin, DSH checks its declared DSH requirement again Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH requirement when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one and listed in `skippedBundles`. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` DSH requirement, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust. -Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires. +Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires. `validatePluginVersionExemption(packageVersion, runtimeVersion)` validates canonical exact identities without granting permission; invalid identities throw. A bundle may declare `dsh.bundle.platforms` as a nonempty list of Node Host platform names, such as `["darwin"]`. The launcher validates this declaration before reading the bundle patch. An unsupported Host skips the entire layer, retains its saved selection, and reports the reason through `skippedBundles`; other layers remain effective. Omission permits every platform. Values must be Node platform names; aliases such as `macos` are invalid. Browser clients do not determine Host support. @@ -145,7 +145,9 @@ This section explains how the outcomes above are realized and points at the code - **One rejection checkpoint.** `inactiveEntries` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. - **Two-stage failure labels.** Outside startup audit failures, `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack. Plugin diagnostics retain nested causes and aggregate member failures; cyclic causes stop traversal without replacing the original error. -The startup error also retains inactive-entry metadata and raw startup warning/error records without retaining the Loader tree. Its `entries` and `startup` fields are non-enumerable: direct access and full diagnostic reports retain them, while ordinary error inspection omits them. Pending-only failures have no `cause`; failures with recorded errors retain their original values in an `AggregateError`. Import errors are collected through the logger before the Loader mounts because no failed Fiber exists for those imports. The temporary exporter is removed when boot settles. +The startup error also retains inactive-entry metadata and raw startup warning/error records without retaining the Loader tree. Its `entries`, `startup`, and `composition` fields are non-enumerable: direct access and full diagnostic reports retain them, while ordinary error inspection omits them. Pending-only failures have no `cause`; failures with recorded errors retain their original values in an `AggregateError`. Import errors are collected through the logger before the Loader mounts because no failed Fiber exists for those imports. The temporary exporter is removed when boot settles. + +The package exports `StartupCompositionDiagnostic`, `StartupPluginState`, and `StartupServiceState` for typed consumers. Before disposal, `composition` captures up to 256 configured plugin rows and 256 missing-service observations, with omission counts. Rows include module, available package version, state and Loader source URL; configuration values are excluded. Registered providers include their state and whether their scope matches the consumer. An empty provider list means ownership is unknown: disabled or failed plugins may never have registered. The Loader source does not identify which overlay last changed a row. Metadata lookup failures leave the version unknown; snapshot failure preserves the original startup error. ### Helper behavior diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 36e03ac63c..a4d8554ca5 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -53,7 +53,7 @@ profile 导入插件前,DSH 会将其声明的 DSH 版本要求与 `getDshRunt 检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH 版本要求;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过,并列入 `skippedBundles`。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` DSH 版本要求,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。 -精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。 +精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。`validatePluginVersionExemption(packageVersion, runtimeVersion)` 校验规范的精确身份而不授予权限;身份非法时抛出错误。 组合包可以用 `dsh.bundle.platforms` 声明非空的 Node Host 平台名称列表,例如 `["darwin"]`。启动器在读取组合包 patch 前验证此声明。Host 不受支持时会跳过整个配置层,保留已保存的选择,并通过 `skippedBundles` 报告原因;其他配置层继续生效。省略此字段时允许所有平台。值必须是 Node 平台名,`macos` 等别名无效。浏览器所在机器的平台不决定 Host 是否受支持。 @@ -145,7 +145,9 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 - **单一 rejection 检查点。** `inactiveEntries` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。 - **两阶段失败标签。** 除启动审计失败外,`boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`,并追加最深层插件错误的堆栈。插件诊断保留嵌套原因和聚合错误中的各项失败;原因链出现循环时会停止遍历,但不会替换原始错误。 -启动错误还保留未激活条目的元数据和原始启动警告、错误记录,不保留 Loader tree。其 `entries` 和 `startup` 字段不可枚举:直接访问和完整诊断报告保留这些字段,常规错误检查输出则省略它们。只有等待条目时没有 `cause`;存在已记录错误时,`AggregateError` 保留其原始值。收集器在 Loader 挂载前通过 logger 收集导入错误,因为这些导入尚无 failed Fiber。启动结算后会移除临时 exporter。 +启动错误还保留未激活条目的元数据和原始启动警告、错误记录,不保留 Loader tree。其 `entries`、`startup` 和 `composition` 字段不可枚举:直接访问和完整诊断报告保留这些字段,常规错误检查输出则省略它们。只有等待条目时没有 `cause`;存在已记录错误时,`AggregateError` 保留其原始值。收集器在 Loader 挂载前通过 logger 收集导入错误,因为这些导入尚无 failed Fiber。启动结算后会移除临时 exporter。 + +包导出 `StartupCompositionDiagnostic`、`StartupPluginState` 和 `StartupServiceState`,供消费者按名引用。释放前,`composition` 捕获最多 256 个配置插件条目和 256 个缺失服务观测,并记录省略数量。条目包含模块、可取得的包版本、状态和 Loader 来源 URL,不含配置值。已注册提供者包含其状态及作用域是否与消费者一致。提供者列表为空表示归属未知:被禁用或失败的插件可能从未注册服务。Loader 来源不代表最后修改该条目的 overlay。元数据查询失败时版本标为未知;快照失败保留原始启动错误。 ### Helper 行为 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 6269685a58..acab24ebe4 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -9,6 +9,8 @@ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { inspect, parseEnv } from 'node:util' +import { captureStartupComposition, type StartupCompositionDiagnostic } from './startup-diagnostics.ts' +export type { StartupCompositionDiagnostic, StartupPluginState, StartupServiceState } from './startup-diagnostics.ts' import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type Fiber, type FiberState } from '@deepseek-ai/cordis' @@ -22,7 +24,7 @@ export { sanitizeProfile } from './profile-sanitize.ts' export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts' export { PROFILE_COMPATIBILITY_FILENAME, readProfileCompatibility, readProfileVersionExemptions, - setProfileVersionExemption, type ProfileCompatibility, + setProfileVersionExemption, validatePluginVersionExemption, type ProfileCompatibility, } from './profile-compatibility.ts' import { prepareProfilePatches } from './compatibility-preflight.ts' import { ProfileRuntimeResolution, type RuntimeResolution } from './profile.ts' @@ -1003,6 +1005,8 @@ interface StartupLogRecord { /** Startup audit failure with non-enumerable metadata and original failures as its cause. */ export class StartupError extends Error { + /** Plugin states captured before disposal, excluding configuration values. */ + composition?: StartupCompositionDiagnostic /** Root configuration and startup logs, attached by boot after disposal. */ startup?: { configurationPath: string; messages: readonly StartupLogRecord[] } @@ -1016,6 +1020,7 @@ export class StartupError extends Error { Object.defineProperties(this, { entries: { enumerable: false }, startup: { enumerable: false }, + composition: { enumerable: false }, }) } } @@ -1238,6 +1243,11 @@ export async function boot( await auditStartupEntries(ctx, binName) return ctx } catch (cause) { + if (cause instanceof StartupError) { + try { cause.composition = captureStartupComposition(ctx) } catch (error) { + ctx.logger.warn('Startup composition diagnostic unavailable', error) + } + } // Root-fiber disposal contains cleanup failures per observer (Cordis // fiber.ts hardening) and a repeated call returns the settled single-shot // result, so this await cannot reject and replace `cause`. diff --git a/packages/boot/app-boot/src/startup-diagnostics.ts b/packages/boot/app-boot/src/startup-diagnostics.ts new file mode 100644 index 0000000000..7982ccc111 --- /dev/null +++ b/packages/boot/app-boot/src/startup-diagnostics.ts @@ -0,0 +1,104 @@ +/** Configuration-free snapshots captured before a failed startup disposes its plugins. */ + +import { Context, type FiberState } from '@deepseek-ai/cordis' +import type { Entry } from '@deepseek-ai/cordis-plugin-loader' +import type {} from './profile-resolution/service.ts' + +/** One configured entry at the time startup failed. */ +export interface StartupPluginState { + readonly id: string + readonly module: string + readonly version: string + readonly state: 'disabled' | 'active' | 'pending' | 'failed' | 'disposed' | 'loading' | 'unloading' + /** Loader source URL; individual overlay ownership may be unavailable. */ + readonly source: string + readonly missing: readonly string[] +} + +/** Observed registrations for one service missing from a consumer. */ +export interface StartupServiceState { + readonly consumer: string + readonly service: string + /** Empty means no registration was observed, not that no configured plugin can provide it. */ + readonly providers: readonly { + readonly entry: string + readonly state: string + readonly visibility: 'same-scope' | 'other-scope' + }[] +} + +/** Bounded startup state without plugin config, service values, or logger arguments. */ +export interface StartupCompositionDiagnostic { + readonly plugins: readonly StartupPluginState[] + readonly services: readonly StartupServiceState[] + readonly omittedPlugins: number + readonly omittedServices: number +} + +function stateName(state: FiberState): StartupPluginState['state'] { + const names: Record = { + 0: 'pending', 1: 'loading', 2: 'active', 3: 'failed', 4: 'disposed', 5: 'unloading', + } + return names[state] +} + +function pluginState(ctx: Context, entry: Entry): StartupPluginState { + let state: StartupPluginState['state'] + try { + state = entry.disabled ? 'disabled' : entry.fiber === undefined ? 'failed' : stateName(entry.fiber.state) + } catch (_error) { + // The activation diagnostic owns the disabled-expression error. + state = 'failed' + } + let version = 'unknown' + const source = entry.parent.tree.ctx.baseUrl + try { + if (source !== undefined) version = ctx.get('pluginPackages')?.packageOf(entry.options.name, source)?.version ?? 'unknown' + } catch (_error) { + // Missing or damaged metadata must not replace the startup failure. + } + const fiber = entry.fiber + return { + id: entry.id, module: entry.options.name, version, state, source: source ?? 'unknown', + missing: state === 'pending' && fiber !== undefined + ? Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined) : [], + } +} + +/** + * Capture Loader state and registered service ownership before teardown. + * Providers that never registered cannot be inferred from their package names. + * @param ctx - Live application context with its Loader still mounted. + * @returns At most 256 plugin rows and 256 missing-service observations, with omission counts. + */ +export function captureStartupComposition(ctx: Context): StartupCompositionDiagnostic { + const entries = [...ctx.loader.entries()] + const rows = entries.map(entry => ({ entry, state: pluginState(ctx, entry) })) + // Inactive rows precede healthy rows so bounded reports retain the failure context. + rows.sort((a, b) => Number(a.state.state === 'active') - Number(b.state.state === 'active')) + const services: StartupServiceState[] = [] + let omittedServices = 0 + const store = ctx.reflect.store + for (const { entry, state } of rows) { + const fiber = entry.fiber + if (fiber === undefined) continue + for (const service of state.missing) { + if (services.length === 256) { omittedServices++; continue } + const scope = fiber.ctx[Context.isolate][service] + const providers: StartupServiceState['providers'][number][] = [] + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + if (impl?.name !== service) continue + providers.push({ + entry: impl.fiber.entry?.id ?? 'unknown', state: stateName(impl.fiber.state), + visibility: key === scope ? 'same-scope' : 'other-scope', + }) + } + services.push({ consumer: state.id, service, providers }) + } + } + return { + plugins: rows.slice(0, 256).map(row => row.state), services, + omittedPlugins: Math.max(0, rows.length - 256), omittedServices, + } +} diff --git a/packages/boot/app-boot/tests/startup-diagnostics.spec.ts b/packages/boot/app-boot/tests/startup-diagnostics.spec.ts new file mode 100644 index 0000000000..9d55d4808b --- /dev/null +++ b/packages/boot/app-boot/tests/startup-diagnostics.spec.ts @@ -0,0 +1,133 @@ +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, expectTypeOf, it } from 'vitest' +import { boot, StartupError, type StartupCompositionDiagnostic, type StartupPluginState, type StartupServiceState } from '../src/index.ts' +import { captureStartupComposition } from '../src/startup-diagnostics.ts' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function configuration(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-startup-diagnostic-')) + roots.push(root) + await writeFile(join(root, 'consumer.mjs'), 'export const inject = ["agents"]; export function apply() {}\n') + await writeFile(join(root, 'provider.mjs'), 'export function apply(ctx) { ctx.provide("agents", {}) }\n') + await writeFile(join(root, 'cordis.yml'), [ + '- id: agent', ' name: ./provider.mjs', ' disabled: true', + ' config:', ' privateValue: do-not-export-this', + '- id: agent-loop', ' name: ./consumer.mjs', '', + ].join('\n')) + return join(root, 'cordis.yml') +} + +it('retains disabled and waiting entries after the real Loader has been disposed', async () => { + const error: unknown = await boot('diagnostic-test', await configuration()).catch((failure: unknown) => failure) + expect(error).toBeInstanceOf(StartupError) + if (!(error instanceof StartupError)) throw error + expect(error.composition?.plugins).toEqual(expect.arrayContaining([ + expect.objectContaining({ module: './provider.mjs', state: 'disabled', version: 'unknown' }), + expect.objectContaining({ module: './consumer.mjs', state: 'pending', missing: ['agents'] }), + ])) + expect(error.composition?.services).toEqual([ + expect.objectContaining({ service: 'agents', providers: [] }), + ]) + expect(JSON.stringify(error.composition)).not.toContain('do-not-export-this') +}) + +it('preserves the startup failure when the Loader is already unavailable to diagnostic capture', async () => { + const failure = new StartupError('startup was interrupted', []) + const result: unknown = await boot('diagnostic-test', await configuration(), undefined, async (ctx) => { + await ctx.fiber.dispose() + throw failure + }).catch((error: unknown) => error) + expect(result).toBe(failure) + expect(failure.composition).toBeUndefined() + expect(failure.startup?.messages.some(message => message.args[0] === 'Startup composition diagnostic unavailable')).toBe(true) +}) + +it('identifies a registered provider that is isolated from the waiting consumer', async () => { + const error: unknown = await boot('diagnostic-test', await configuration(), undefined, async (ctx) => { + await ctx.isolate('agents').plugin({ + name: 'isolated-agent-provider', + apply(provider) { provider.reflect.provide('agents', {}) }, + }) + }).catch((failure: unknown) => failure) + expect(error).toBeInstanceOf(StartupError) + if (!(error instanceof StartupError)) throw error + expect(error.composition?.services).toEqual([ + expect.objectContaining({ service: 'agents', providers: [{ entry: 'unknown', state: 'active', visibility: 'other-scope' }] }), + ]) +}) + +it('reports omitted plugin and missing-service counts instead of silently dropping rows', async () => { + const config = await configuration() + const root = roots.at(-1)! + const services = Array.from({ length: 257 }, (_, index) => `missing${index}`) + await writeFile(join(root, 'consumer.mjs'), `export const inject = ${JSON.stringify(services)}; export function apply() {}\n`) + await writeFile(config, [ + '- id: agent-loop\n name: ./consumer.mjs', + ...Array.from({ length: 257 }, (_, index) => `- id: disabled-${index}\n name: ./provider.mjs\n disabled: true`), '', + ].join('\n')) + const error: unknown = await boot('diagnostic-test', config).catch((failure: unknown) => failure) + if (!(error instanceof StartupError)) throw error + expect(error.composition?.plugins).toHaveLength(256) + expect(error.composition?.omittedPlugins).toBeGreaterThanOrEqual(2) + expect(error.composition?.services).toHaveLength(256) + expect(error.composition?.omittedServices).toBe(1) +}) + +it('reports a registered same-scope provider whose value is unavailable', async () => { + const config = await configuration() + await writeFile(join(roots.at(-1)!, 'provider.mjs'), 'export function apply(ctx) { ctx.provide("agents", undefined) }\n') + await writeFile(join(roots.at(-1)!, 'consumer.mjs'), 'export const inject = ["agents", "unprovided"]; export function apply() {}\n') + await writeFile(config, '- id: agent\n name: ./provider.mjs\n- id: agent-loop\n name: ./consumer.mjs\n') + const error: unknown = await boot('diagnostic-test', config) + .catch((failure: unknown) => failure) + if (!(error instanceof StartupError)) throw error + expect(error.composition?.services[0]?.providers).toMatchObject([ + { state: 'active', visibility: 'same-scope' }, + ]) + expect(error.composition?.services[0]?.providers[0]?.entry).toContain('agent') +}) + +it('retains the Loader disablement after disposal and reports missing source metadata', async () => { + const config = await configuration() + await writeFile(config, '- id: agent\n name: ./provider.mjs\n') + const ctx = await boot('diagnostic-test', config) + try { + const entry = [...ctx.loader.entries()].find(row => row.options.id === 'agent')! + await entry.fiber!.dispose() + Object.defineProperty(entry.parent.tree.ctx, 'baseUrl', { value: undefined, configurable: true }) + const snapshot = captureStartupComposition(ctx) + expect(snapshot.plugins).toContainEqual(expect.objectContaining({ module: './provider.mjs', state: 'disabled', source: 'unknown' })) + } finally { + await ctx.fiber.dispose() + } +}) + +it.each(['same-scope', 'other-scope'] as const)('attributes a nested %s provider to its Loader entry', async (visibility) => { + const config = await configuration() + const parent = visibility === 'same-scope' ? 'ctx' : 'ctx.isolate("agents")' + await writeFile(join(roots.at(-1)!, 'consumer.mjs'), 'export const inject = ["agents", "unprovided"]; export function apply() {}\n') + await writeFile(join(roots.at(-1)!, 'provider.mjs'), `export async function apply(ctx) { await ${parent}.plugin({ name: "nested-provider", apply(child) { child.provide("agents", undefined) } }) }\n`) + await writeFile(config, '- id: agent\n name: ./provider.mjs\n- id: agent-loop\n name: ./consumer.mjs\n') + const failure: unknown = await boot('diagnostic-test', config).then(async (ctx) => { + await ctx.fiber.dispose() + throw new Error('Expected the missing service to prevent startup') + }, (error: unknown) => error) + if (!(failure instanceof StartupError)) throw failure + const owner = failure.composition?.plugins.find(plugin => plugin.module === './provider.mjs') + expect(owner).toBeDefined() + expect(failure.composition?.services[0]?.providers).toEqual([ + { entry: owner!.id, state: 'active', visibility }, + ]) +}) + +it('exports the named types used by startup composition diagnostics', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() +}) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 64f0952fc3..b40049c632 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -27,8 +27,8 @@ en: 99b084ff497011d2 zh: 6c047796c1991784 /deepseek-ai-dsh-plugin-manager/understand-the-implementation: - en: 495dadf79c48c43c - zh: 9884dd04a5bb49dc + en: c224bfcb3eb3dbd2 + zh: 8b9c96970a90dccc /deepseek-ai-dsh-plugin-manager/further-exploration: en: c6538243d8702a0d zh: 04a82d923353c75f diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 16d2273cb7..fd75a0d46a 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -129,6 +129,10 @@ A service run whose captured output stays silent for `idleTimeoutMs` is terminat Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled package run, or failed bundle validation, restores the manifest and lockfile snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); later failures retain saved changes as described in [Failure behavior](#failure-behavior). Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. +The `plugin-manager/operation` event correlates each service invocation with its start and observed result, or an unknown outcome when an exception escapes result construction. Installation targets retain only parsed registry package names; bundle toggles and removals require the exact package name without a version range or surrounding whitespace. Plugin targets retain only known entry IDs. Recorded targets need not be installed; operations that require their files still refuse missing packages. Version-exemption records retain the validated package name and exact exemption version, not the currently installed version. If either the package or runtime identity is invalid, target is null and version is omitted. Exemptions reuse reason `bundle`, stage `enable`, and the exemption's `enabled` value; when the exemption version matches the installed version, their records cannot distinguish an exemption change from a bundle toggle. Versions describe request-time observations before locking or the resolved installation; invalid or oversized metadata is omitted without losing the operation. Platform refusals retain the `unsupported-platform` error code. Raw specs and error text are excluded. Subscribers run independently without delaying management; synchronous throws and asynchronous rejections do not starve later subscribers or change the result. Direct CLI package operations do not emit this service event. + +The `@deepseek-ai/dsh-plugin-manager/diagnostic` entry supplies Desktop Host and shell readers with run IDs, operation records and a parser that retains only whitelisted fields. Malformed or oversized records are rejected; readers report omissions and truncation. The shell accepts records only for the run ID announced by its Host. Diagnostic consumers own persistence and delivery limits; missing records never imply successful operations. + ----- diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 67462df04a..46a95a412e 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -129,6 +129,10 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, 结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。包管理运行失败或被取消,或组合包校验失败时,会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));后续失败按[失败行为](#failure-behavior)所述保留已保存的改动。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。 +`plugin-manager/operation` 事件关联每次服务调用的开始和已观察到的结果;若异常使结果无法构造,则记录结果未知。安装目标仅保留解析出的 registry 包名;启停或移除包的目标必须与包名完全一致,不能含版本范围或首尾空格;插件目标仅保留已知条目 ID。记录的目标不要求已安装;需要读取包文件的操作仍会拒绝不存在的包。版本豁免记录保留已校验的包名和精确豁免版本,而不是当前安装版本。包或运行时任一身份非法时,目标记为 null,版本省略。豁免复用 reason `bundle`、stage `enable` 和豁免的 `enabled` 值;豁免版本与安装版本相同时,记录无法区分豁免变更与 bundle 开关。版本表示加锁前请求时观察到的值或安装解析出的值;非法或过长的元数据被省略,不丢弃操作记录。平台拒绝保留 `unsupported-platform` 错误码。事件不包含原始 spec 和错误文本。订阅者独立执行,不延迟管理操作;同步抛错和异步拒绝都不会阻断后续订阅者或改变结果。直接执行的 CLI 包操作不发送此服务事件。 + +`@deepseek-ai/dsh-plugin-manager/diagnostic` 入口为 Desktop Host 和壳读取器提供运行 ID、操作记录及仅保留白名单字段的解析器。格式错误或超限记录会被拒绝;读取器报告遗漏和截断。壳仅接受其 Host 已声明运行 ID 对应的记录。诊断消费者负责持久化和传输限额;缺失记录不能说明操作成功。 + ----- diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index 3822c32f62..196eeb5a20 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -45,7 +45,11 @@ "default": "./lib/types/tools.js" }, "./tools/locale/*.json": "./locale/tools/*.json", - "./locale/*.json": "./locale/*.json" + "./locale/*.json": "./locale/*.json", + "./diagnostic": { + "types": "./lib/types/diagnostic.d.ts", + "default": "./lib/types/diagnostic.js" + } }, "files": [ "locale/*.json", diff --git a/packages/boot/plugin-manager/src/diagnostic.ts b/packages/boot/plugin-manager/src/diagnostic.ts new file mode 100644 index 0000000000..aa79fe5527 --- /dev/null +++ b/packages/boot/plugin-manager/src/diagnostic.ts @@ -0,0 +1,85 @@ +/** Whitelisted operation records shared by diagnostic transport and durable readers. */ +import { z } from 'zod' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ChangeResult, ManagementError, PluginChange, PluginOperation, PluginOperationId, RestartReason } from './types.ts' + +/** Identifies the process lifetime which observed management operations. */ +export type PluginOperationRunId = Branded<'PluginOperationRunId'> + +/** One diagnostic event; omissions and unknown outcomes must not be interpreted as successful operations. */ +export interface PluginOperationRecord { + readonly runId: PluginOperationRunId + readonly operation: PluginOperation + readonly transportDropped: number +} + +const fields = { + id: z.uuid().transform(value => value as PluginOperationId), + time: z.iso.datetime(), + reason: z.enum({ + 'plugin': 'plugin', + 'bundle': 'bundle', + 'install': 'install', + 'remove': 'remove', + } satisfies { [K in PluginChange['reason']]: K }), + target: z.string().max(256).nullable(), + stage: z.enum({ + 'install': 'install', + 'enable': 'enable', + 'remove': 'remove', + } satisfies { [K in ChangeResult['stage']]: K }), + enabled: z.boolean().optional(), + version: z.string().max(128).optional(), +} +const operation: z.ZodType = z.discriminatedUnion('phase', [ + z.object({ ...fields, phase: z.literal('started') }), + z.object({ ...fields, phase: z.literal('unknown') }), + z.object({ ...fields, phase: z.literal('settled'), + application: z.enum({ + 'applied': 'applied', + 'restart-required': 'restart-required', + 'overridden': 'overridden', + 'failed': 'failed', + 'cancelled': 'cancelled', + } satisfies { [K in ChangeResult['application']]: K }), + changed: z.boolean(), + errorCode: z.enum({ + 'management-required': 'management-required', + 'unaddressable': 'unaddressable', + 'unknown-plugin': 'unknown-plugin', + 'invalid-spec': 'invalid-spec', + 'already-installed': 'already-installed', + 'ambiguous-install': 'ambiguous-install', + 'not-bundle': 'not-bundle', + 'not-removable': 'not-removable', + 'stop-profile': 'stop-profile', + 'bundle-in-use': 'bundle-in-use', + 'stale-approval': 'stale-approval', + 'incompatible-version': 'incompatible-version', + 'unsupported-platform': 'unsupported-platform', + 'operation-error': 'operation-error', + } satisfies { [K in ManagementError['code']]: K }).optional(), + restartReason: z.enum({ + 'no-hmr': 'no-hmr', + 'package-moved': 'package-moved', + 'host-module': 'host-module', + 'unmanaged-plugin': 'unmanaged-plugin', + 'module-comparison': 'module-comparison', + } satisfies { [K in RestartReason]: K }).optional(), + }), +]) +const record = z.object({ + runId: z.uuid().transform(value => value as PluginOperationRunId), + operation, + transportDropped: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), +}) + +/** + * Validate transport/file data and discard fields outside the diagnostic whitelist. + * @param value - Untrusted decoded JSON. + * @returns A bounded record, or undefined for malformed, oversized or unsupported records. + */ +export function parsePluginOperationRecord(value: unknown): PluginOperationRecord | undefined { + const parsed = record.safeParse(value) + return parsed.success ? parsed.data : undefined +} diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 0d138d9996..271e6af0d3 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -21,7 +21,7 @@ import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, readProfilePatches, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, - setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, + setProfileVersionExemption, validatePluginVersionExemption, PROFILE_COMPATIBILITY_FILENAME, getDshRuntimeVersion, ResolutionRestartRequired, assertBundlePlatform, UnsupportedBundlePlatformError, } from '@deepseek-ai/dsh-app-boot' @@ -44,6 +44,7 @@ import type { PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, Registry, RegistryAttempt, UpdateBundleOptions, + PluginOperation, PluginOperationId, } from './types.ts' export type * from './types.ts' export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts' @@ -278,6 +279,28 @@ function parsedForRegistry(spec: string): ParsedInstallSpec { } } +/** Only registry package names are retained before installation resolves its target. */ +function installOperationTarget(spec: string): string | null { + let parsed: ParsedInstallSpec + try { parsed = parseInstallSpec(spec) } + catch (_error) { return null /* Invalid specs remain private; installation owns their refusal. */ } + return parsed.kind === 'registry' ? parsed.name : null +} + +/** Exemption facts use canonical package identity, never the raw request or installed version. */ +function exemptionOperationIdentity(packageVersion: string, runtimeVersion: string): Pick { + try { validatePluginVersionExemption(packageVersion, runtimeVersion) } + catch (_error) { return { target: null } /* The operation still owns validation failure reporting. */ } + const separator = packageVersion.lastIndexOf('@') + return { target: installOperationTarget(packageVersion.slice(0, separator)), + version: diagnosticVersion(packageVersion.slice(separator + 1)) } +} + +/** Unusable file metadata must not discard the rest of an operation record. */ +function diagnosticVersion(value: unknown): string | undefined { + return typeof value === 'string' && value.length <= 128 ? value : undefined +} + declare module '@deepseek-ai/cordis' { interface Context { /** Persistent management of the current profile's composition and packages. */ @@ -432,7 +455,7 @@ export class PluginManager extends TypertRemoteService { return this.change(result => this.configure(async () => { await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true) Object.assign(result, await this.reload()) - }), { stage: 'enable', target: packageVersion, enabled }, 'bundle') + }), { stage: 'enable', target: packageVersion, enabled }, 'bundle', exemptionOperationIdentity(packageVersion, runtimeVersion)) } /** Read current plugins, including why a row cannot be changed through the profile patch. @@ -1226,30 +1249,86 @@ export class PluginManager extends TypertRemoteService { operation: (result: ChangeResult) => Promise, request: Pick, reason: PluginChange['reason'], + identity?: Pick, ): Promise { - return withFileLock(join(this.profile.dir, 'package.json'), async () => { - this.abort.signal.throwIfAborted() - const before = this.diskState() - const result: ChangeResult = { ...request, changed: false, - ...this.ownerContext.get('hmr') !== undefined ? { application: 'applied' } : { application: 'restart-required', restartReason: 'no-hmr' } } - try { - const outcome = await operation(result) - if (outcome !== undefined) result.application = outcome - // A reload that waits for a restart leaves the change saved but not applied. - else if (result.application === 'applied' && result.restartReason !== undefined) result.application = 'restart-required' - } catch (error) { - if (error instanceof InstallCancelledError) { - result.application = 'cancelled' - } else { - result.application = 'failed' - result.error = managementError(error) + const id = randomUUID() as PluginOperationId + const target = identity === undefined ? this.operationTarget(reason, request.target) : identity.target + const version = identity === undefined ? this.operationVersion(reason, reason === 'plugin' ? request.target : target) : identity.version + const facts = { id, reason, stage: request.stage, + target, + ...version === undefined ? {} : { version }, + ...request.enabled === undefined ? {} : { enabled: request.enabled } } + this.reportOperation({ ...facts, time: new Date().toISOString(), phase: 'started' }) + let settled: ChangeResult + try { + settled = await withFileLock(join(this.profile.dir, 'package.json'), async () => { + this.abort.signal.throwIfAborted() + const before = this.diskState() + const result: ChangeResult = { ...request, changed: false, + ...this.ownerContext.get('hmr') !== undefined ? { application: 'applied' } : { application: 'restart-required', restartReason: 'no-hmr' } } + try { + const outcome = await operation(result) + if (outcome !== undefined) result.application = outcome + // A reload that waits for a restart leaves the change saved but not applied. + else if (result.application === 'applied' && result.restartReason !== undefined) result.application = 'restart-required' + } catch (error) { + if (error instanceof InstallCancelledError) { + result.application = 'cancelled' + } else { + result.application = 'failed' + result.error = managementError(error) + } } - } - if (result.application !== 'restart-required') delete result.restartReason - result.changed = before !== this.diskState() - this.ownerContext.emit('plugin-manager/changed', { reason }) - return result - }, { waitMs: this.lockWaitMs }) + if (result.application !== 'restart-required') delete result.restartReason + result.changed = before !== this.diskState() + this.ownerContext.emit('plugin-manager/changed', { reason }) + return result + }, { waitMs: this.lockWaitMs }) + } catch (error) { + this.reportOperation({ ...facts, time: new Date().toISOString(), phase: 'unknown' }) + throw error + } + const refusedVersion = reason === 'install' + ? settled.error?.incompatible?.find(plugin => plugin.name === facts.target)?.version + : undefined + const observedVersion = diagnosticVersion(settled.version ?? refusedVersion) + this.reportOperation({ ...facts, time: new Date().toISOString(), phase: 'settled', + stage: settled.stage, target: settled.bundle === undefined ? facts.target : installOperationTarget(settled.bundle), + application: settled.application, changed: settled.changed, + ...observedVersion === undefined ? {} : { version: observedVersion }, + ...settled.error === undefined ? {} : { errorCode: settled.error.code }, + ...settled.restartReason === undefined ? {} : { restartReason: settled.restartReason } }) + return settled + } + + private reportOperation(operation: PluginOperation): void { + void this.ownerContext.parallel('plugin-manager/operation', operation).catch((_error: unknown) => { + // All subscribers run; diagnostic failures cannot change the management result. + console.error('dsh plugin-manager: operation diagnostic delivery failed') + }) + } + + private operationTarget(reason: PluginChange['reason'], target: string): string | null { + if (reason !== 'plugin') { + const name = installOperationTarget(target) + return reason === 'install' || name === target ? name : null + } + return target.length <= 256 && [...this.ctx.loader.entries()].some(entry => entry.id === target) ? target : null + } + + /** Read the request-time package version without preventing recovery from damaged metadata. */ + private operationVersion(reason: PluginChange['reason'], target: string | null): string | undefined { + if (reason === 'install' || target === null) return undefined + try { + if (reason !== 'plugin') return diagnosticVersion(bundleManifest(target, this.profile.dir, this.profile.installAnchor)?.version) + const entry = [...this.ctx.loader.entries()].find(row => row.id === target) + const source = entry?.parent.tree.ctx.baseUrl + if (entry === undefined || source === undefined) return undefined + return diagnosticVersion(this.ownerContext.get('pluginPackages')?.packageOf(entry.options.name, source)?.version) + } catch (_error) { + // Disabling or removing a broken package must not depend on diagnostic metadata being readable. + return undefined + } } private diskState(): string { diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 1e72222e81..efef87b4dc 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -382,8 +382,41 @@ export interface PluginChange { readonly reason: 'plugin' | 'bundle' | 'install' | 'remove' } +/** Identifies one manager invocation, including time spent waiting for its profile lock. */ +export type PluginOperationId = Branded<'PluginOperationId'> + +/** Management facts for diagnostic consumers; recent operations do not establish a crash's cause. */ +export type PluginOperation = { + readonly id: PluginOperationId + readonly time: string + readonly reason: PluginChange['reason'] + /** Known entry id or parsed registry package name; unrecognized targets and raw specs are omitted. */ + readonly target: string | null + readonly stage: ChangeResult['stage'] + readonly enabled?: boolean | undefined + /** Request-time or resolved package version; absence means unusable or unavailable, never the latest registry version. */ + readonly version?: string | undefined +} & ( + | { readonly phase: 'started' } + | { + readonly phase: 'settled' + readonly application: ChangeResult['application'] + readonly changed: boolean + readonly errorCode?: ManagementError['code'] | undefined + readonly restartReason?: RestartReason | undefined + } + /** An exception escaped the operation result; persisted or applied changes cannot be inferred. */ + | { readonly phase: 'unknown' } +) + declare module '@deepseek-ai/cordis' { interface Events { + /** + * One manager invocation started or ended; subscribers run independently without delaying management. + * @mode parallel + * @param operation - Correlated operation facts; a start without an end has an unknown outcome. + */ + 'plugin-manager/operation'(operation: PluginOperation): void | Promise /** * The profile's plugins, bundles, or composition changed: a manager * operation completed. A patch generation applied outside the manager, diff --git a/packages/boot/plugin-manager/tests/diagnostic.spec.ts b/packages/boot/plugin-manager/tests/diagnostic.spec.ts new file mode 100644 index 0000000000..351e4d9556 --- /dev/null +++ b/packages/boot/plugin-manager/tests/diagnostic.spec.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest' +import { parsePluginOperationRecord } from '../src/diagnostic.ts' + +const input = { + runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0, + operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z', + reason: 'plugin', phase: 'settled', target: 'managed', stage: 'enable', changed: true, application: 'restart-required' }, +} + +it('keeps only diagnostic fields at both record levels', () => { + expect(parsePluginOperationRecord({ ...input, credential: 'secret', operation: { + ...input.operation, diagnostic: 'raw secret output', packageResult: { output: 'raw output' }, + } })).toEqual(input) +}) + +it('rejects unsupported phases, oversized identifiers and invalid result fields', () => { + for (const patch of [{ phase: 'future' }, { id: 'invalid' }, { target: 'x'.repeat(257) }, + { time: 'not a time' }, { changed: 'yes' }, { application: 'unknown-success' }, { errorCode: 'raw-message' }]) { + expect(parsePluginOperationRecord({ ...input, operation: { ...input.operation, ...patch } })).toBeUndefined() + } + expect(parsePluginOperationRecord({ ...input, transportDropped: -1 })).toBeUndefined() + expect(parsePluginOperationRecord({ ...input, runId: '' })).toBeUndefined() +}) + +it('retains an observed version before an operation settles', () => { + const started = { ...input, operation: { ...input.operation, phase: 'started', version: '1.2.3' } } + expect(parsePluginOperationRecord(started)?.operation).toMatchObject({ phase: 'started', version: '1.2.3' }) + expect(parsePluginOperationRecord({ ...started, operation: { ...started.operation, version: 'x'.repeat(129) } })).toBeUndefined() +}) + +it('retains platform refusals in settled operation records', () => { + const record = { ...input, operation: { ...input.operation, application: 'failed', changed: false, errorCode: 'unsupported-platform' } } + expect(parsePluginOperationRecord(record)).toEqual(record) +}) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index f2eb46af9a..0f8290a48d 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -17,6 +17,7 @@ import { } from '@deepseek-ai/dsh-app-boot' import { OPTIONAL_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG } from '@deepseek-ai/dsh-official-bundles' import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts' +import type { PluginEntryId, PluginOperation } from '../src/types.ts' import Hmr from '@deepseek-ai/dsh-hmr' import Timer from '@deepseek-ai/cordis-plugin-timer' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' @@ -554,6 +555,112 @@ it.each([ }) }) +it('correlates diagnostic operation starts with persisted toggle results', async () => { + const { manager, ctx, dir } = await fixture('startup') + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + await manager.setBundleEnabled('extra', false) + expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('extra') + expect(facts).toHaveLength(2) + expect(facts[0]).toMatchObject({ phase: 'started', reason: 'bundle', target: 'extra', enabled: false, version: '1.0.0' }) + expect(facts[1]).toMatchObject({ id: facts[0]!.id, phase: 'settled', changed: true, application: 'restart-required', version: '1.0.0' }) + await manager.setBundleEnabled('extra', true) + expect(facts[2]!.id).not.toBe(facts[0]!.id) +}) + +it.each(['{', ...[null, 12, {}, 'x'.repeat(129)].map(version => JSON.stringify({ version, dsh: { bundle: { patch: './cordis.patch.yml' } } }))])('retains an observed version for a refused removal and leaves damaged metadata unknown: %s', async (manifest) => { + const { manager, ctx, dir } = await fixture('startup') + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + expect(await manager.removeBundle('extra')).toMatchObject({ application: 'failed', error: { code: 'stop-profile' } }) + expect(facts).toEqual([ + expect.objectContaining({ phase: 'started', reason: 'remove', version: '1.0.0' }), + expect.objectContaining({ phase: 'settled', reason: 'remove', version: '1.0.0', application: 'failed' }), + ]) + writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), manifest) + expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'restart-required' }) + expect(facts[2]).not.toHaveProperty('version') + expect(facts[3]).not.toHaveProperty('version') +}) + +it('excludes raw install specs and error diagnostics from operation facts', async () => { + const { manager, ctx } = await fixture('startup') + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + await manager.installBundle('--https://user:secret@example.invalid/private?token=secret') + expect(facts).toHaveLength(2) + expect(facts[1]).toMatchObject({ phase: 'settled', target: null, application: 'failed', errorCode: 'invalid-spec' }) + expect(JSON.stringify(facts)).not.toMatch(/secret|example|diagnostic|packageResult/) +}) + +it('keeps credential-bearing tarball addresses out of failed installation facts', async () => { + const { manager, ctx } = await fixture('startup') + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + const install = vi.spyOn(operations, 'runProfilePnpm').mockRejectedValue(new Error('private download failed')) + onTestFinished(() => { install.mockRestore() }) + expect(await manager.installBundle('https://user:secret@example.invalid/private/archive.tgz')) + .toMatchObject({ application: 'failed' }) + expect(facts).toHaveLength(2) + expect(facts.map(event => event.target)).toEqual([null, null]) + expect(JSON.stringify(facts)).not.toMatch(/user|secret|example|private|archive/) +}) + +it.each(['bundle', 'remove', 'plugin'] as const)('excludes credential-bearing targets from %s diagnostics', async (reason) => { + const { manager, ctx } = await fixture('startup') + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + const target = 'https://user:secret@example.invalid/private?token=secret' + if (reason === 'bundle') await manager.setBundleEnabled(target, false) + else if (reason === 'remove') await manager.removeBundle(target) + else await manager.setPluginEnabled(target as PluginEntryId, false) + expect(facts).toHaveLength(2) + expect(facts.map(event => event.target)).toEqual([null, null]) + expect(JSON.stringify(facts)).not.toMatch(/user|secret|example|private/) +}) + +it.each(['extra@9.9.9', ' extra', 'extra '])('does not attribute a non-name target %j to an installed bundle', async (target) => { + const { manager, ctx } = await fixture('startup') + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + await manager.setBundleEnabled(target, false) + await manager.removeBundle(target) + expect(facts).toHaveLength(4) + for (const event of facts) { + expect(event.target).toBeNull() + expect(event).not.toHaveProperty('version') + } +}) + +it('reports unknown outcomes when profile inspection rejects before a result exists', async () => { + const { manager, ctx, dir } = await fixture('startup') + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + const patch = join(dir, 'cordis.patch.yml') + rmSync(patch, { force: true }) + mkdirSync(patch) + await expect(manager.setBundleEnabled('extra', false)).rejects.toThrow() + expect(facts.map(event => event.phase)).toEqual(['started', 'unknown']) + expect(facts[1]!.id).toBe(facts[0]!.id) + expect(facts[1]).not.toHaveProperty('changed') +}) + +it.each(['throw', 'reject'])('delivers diagnostics to later subscribers when an earlier subscriber fails: %s', async (failure) => { + const { manager, ctx, dir } = await fixture('startup') + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { errors.mockRestore() }) + ctx.on('plugin-manager/operation', () => { + if (failure === 'reject') return Promise.reject(new Error('diagnostic sink unavailable')) + throw new Error('diagnostic sink unavailable') + }) + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'restart-required' }) + expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('extra') + expect(facts.map(event => event.phase)).toEqual(['started', 'settled']) + await vi.waitFor(() => { expect(errors).toHaveBeenCalledWith('dsh plugin-manager: operation diagnostic delivery failed') }) +}) + it('turns a plugin off and on without leaving a patch override the bundle already implies', async () => { const { manager, dir } = await fixture() const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId @@ -1594,6 +1701,8 @@ it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and p expect(await manager.setVersionExemption('extra@1.0.0', runtime, true)).toMatchObject({ changed: false, application: 'failed' }) expect(manager.listVersionExemptions()).toEqual({ exemptions: {}, warnings: [] }) expect(existsSync(join(dir, 'compatibility.json'))).toBe(false) + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) expect(await manager.setVersionExemption('extra@1.0.0', runtime, true, true)).toMatchObject({ changed: true, application: mode === 'live' ? 'applied' : 'restart-required', }) @@ -1606,21 +1715,31 @@ it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and p if (mode === 'live') expect(managed()?.fiber?.state).toBe(2) else expect(managed()).toBeUndefined() expect(await manager.setVersionExemption('extra@1.0.0', runtime, false)).toMatchObject({ changed: true }) + expect(facts.map(({ phase, target, version }) => ({ phase, target, version }))).toEqual([ + { phase: 'started', target: 'extra', version: '1.0.0' }, + { phase: 'settled', target: 'extra', version: '1.0.0' }, + { phase: 'started', target: 'extra', version: '1.0.0' }, + { phase: 'settled', target: 'extra', version: '1.0.0' }, + ]) expect(manager.listVersionExemptions()).toEqual({ exemptions: {}, warnings: [] }) expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({}) if (mode === 'live') expect(managed()).toBeUndefined() }) -it('reports a package run refused for compatibility as a typed refusal', async () => { - const { manager } = await fixture() +it.each(['dsh-x', 'dsh-x@^2.0.0'])('retains the refused package identity for %s', async (spec) => { + const { manager, ctx } = await fixture() + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) const incompatible = [{ name: 'dsh-x', version: '2.0.0', runtimeVersion: getDshRuntimeVersion(), requirements: { '@deepseek-ai/dsh': '999.0.0' } }] const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'dsh: installation rejected', truncated: false, logPath: 'pnpm.log', kind: 'unknown', incompatible, }) onTestFinished(() => { install.mockRestore() }) - const result = await manager.installBundle('dsh-x') + const result = await manager.installBundle(spec) expect(result).toMatchObject({ application: 'failed', changed: false, error: { code: 'incompatible-version', incompatible } }) expect(install).toHaveBeenCalledTimes(1) + expect(facts[0]).toMatchObject({ phase: 'started', target: 'dsh-x' }) + expect(facts[1]).toMatchObject({ phase: 'settled', target: 'dsh-x', version: '2.0.0', errorCode: 'incompatible-version' }) }) it.each([false, true])('rechecks installed bundle peers before accepting a disabled installation (exempted=%s)', async (exempted) => { @@ -2113,3 +2232,52 @@ it('installs over the running version without HMR and reports the restart', asyn expect(steps).toEqual(['checking', 'installing']) expect(pnpm.mock.calls.map(call => call[1])).toEqual([['add', 'extra@1.2.0', '--save-exact']]) }) + +it.each(['extra@^1.0.0', ' extra@1.0.0 ', 'https://user:secret@example.com/pkg'])('omits invalid exemption identity for %s', async (input) => { + const { manager, ctx } = await fixture() + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + expect(await manager.setVersionExemption(input, getDshRuntimeVersion(), true, true)).toMatchObject({ application: 'failed' }) + expect(facts).toHaveLength(2) + for (const fact of facts) { + expect(fact.target).toBeNull() + expect(fact.version).toBeUndefined() + } + expect(JSON.stringify(facts)).not.toContain(input) + expect(JSON.stringify(facts)).not.toContain('secret') +}) + +it.each(['disable', 'enable', 'remove'] as const)('retains exact uninstalled bundle names when attempting %s', async (operation) => { + const { manager, ctx } = await fixture() + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + const result = operation === 'remove' ? await manager.removeBundle('absent') + : await manager.setBundleEnabled('absent', operation === 'enable') + if (operation !== 'disable') expect(result.application).toBe('failed') + expect(facts).toHaveLength(2) + for (const fact of facts) { + expect(fact.target).toBe('absent') + expect(fact.version).toBeUndefined() + } +}) + +it.each(['extra', '@scope/extra'])('records the exemption version independently of installed metadata for %s', async (name) => { + const { manager, ctx } = await fixture() + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + await manager.setVersionExemption(`${name}@2.0.0`, getDshRuntimeVersion(), false) + expect(facts).toHaveLength(2) + for (const fact of facts) expect(fact).toMatchObject({ target: name, version: '2.0.0', enabled: false }) +}) + +it('omits exemption identity when only the runtime version is invalid', async () => { + const { manager, ctx } = await fixture() + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + expect(await manager.setVersionExemption('extra@1.0.0', '^1.0.0', true, true)).toMatchObject({ application: 'failed' }) + expect(facts).toHaveLength(2) + for (const fact of facts) { + expect(fact.target).toBeNull() + expect(fact.version).toBeUndefined() + } +}) diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 4cce54baa5..5adcfb69a3 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -6,7 +6,7 @@ import css from './MessageImage.module.css' export type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' -/** Loads a session-authorized durable image URL and may expose a cached URL synchronously. */ +/** Loads a Gateway-authorized durable image URL and may expose a cached URL synchronously. */ export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise) & { peek?: (attachment: ImageAttachmentRef) => string | undefined } @@ -62,7 +62,7 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon * List thumbnails use status icons with localized tooltips and accessible names. * * @param props.image - the durable reference to load, or the local preview to display. - * @param props.load - session-authorized URL loader for the durable arm. + * @param props.load - Gateway-authorized URL loader for the durable arm. * @param props.variant - lone image, cropped gallery tile, or uncropped list thumbnail. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 7d0ee96bea..dd6588f8bf 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -45,7 +45,7 @@ describe('MessageImage', () => { expect(load).toHaveBeenCalledWith(attachment) }) - it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { + it('loads a Gateway-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 557b9e2a04..9687743249 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -531,7 +531,7 @@ describe('Chat inject API', () => { const loaded = await injected.loadImage(ATTACHMENT) expect(loaded).toEqual(expect.any(String)) - expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId) + expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT) expect(injected.loadImage.peek?.(ATTACHMENT)).toBe(loaded) await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ed29dd1421..49c3fd957d 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -9,8 +9,8 @@ en: 77ecbd074ef3a3ce zh: 2c0e2b720e3b201e /deepseek-ai-dsh-client-ui-conversation/summary: - en: bac0bf0d7330f3b1 - zh: 84a895f4b26d8490 + en: 8811c28013fdc6dc + zh: 0a86edc422b38ba8 /deepseek-ai-dsh-client-ui-conversation/table-of-contents: en: eef4f18c3972ccc4 zh: 6be65c6ac84ce497 @@ -21,8 +21,8 @@ en: 7871f9fe6bcc4459 zh: 0d51565c600b3958 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 63ad16e66a02a67f - zh: d035a62bacbc1bdf + en: d0ff0a313e92df44 + zh: 5a2a44347348dc6b /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e475066fa3..769bd7de23 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -11,7 +11,7 @@ Desktop product events use the optional [product analytics service](../product-a ## Summary -`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller `SessionEventLikeEntry` feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. +`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller `SessionEventLikeEntry` feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one Gateway-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. ## Table of Contents @@ -72,7 +72,7 @@ The shared image slot props keep display choices separate from durable reference The composer uses a minimum 16px draft font on coarse-pointer devices to avoid focus-triggered browser zoom; larger font preferences still apply. -The composer measures its expanded control groups after size, content, visibility, or font-loading changes. If they cannot share a line, the row sets `--dsh-composer-model-text-display: none` and `--dsh-composer-model-icon-display: block` for the model seat; their defaults are `block` and `none`. Wrapping remains available if even the icon cannot fit. +The composer measures its expanded control groups after size, content, visibility, or font-loading changes. If they cannot share a line, the row sets `--dsh-composer-model-text-display: none` and `--dsh-composer-model-icon-display: block` for the model seat; their defaults are `block` and `none`. The model seat is measured at its content width, capped at 360px, rather than at a share of the row, so a long name stays visible in the measurement. Wrapping remains available if even the icon cannot fit. The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale). @@ -82,7 +82,7 @@ The context-occupancy button shows a ring and percentage below the input card, a With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility. -While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Individual tabs are not measured. At least one preferred tab remains outside, and long labels truncate with a full-name tooltip. Unregistered View ids remain in the in-memory pin list until plugin reload. +While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Each tab label estimates its text width from the glyphs; its full-name tooltip appears only when that estimate exceeds the 88px cell. At least one preferred tab remains outside. Unregistered View ids remain in the in-memory pin list until plugin reload. The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 81de85f9f3..ea02f35a88 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -11,7 +11,7 @@ kind: "package-reference" ## 概述 -`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller 的 `SessionEventLikeEntry` feed,通过 `ctx.uiConversation` 暴露不依赖 React 的注册表与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立包,由各自包注册 Definition、快照 builder、View 和 renderer。 +`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller 的 `SessionEventLikeEntry` feed,通过 `ctx.uiConversation` 暴露不依赖 React 的注册表与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经 Gateway 授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立包,由各自包注册 Definition、快照 builder、View 和 renderer。 ## 目录 @@ -72,7 +72,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS 在粗指针设备上,composer 的草稿字号至少为 16px,以避免获得焦点时浏览器自动放大;更大的字号偏好仍然生效。 -控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。若连图标也放不下,仍允许换行。 +控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。模型位按内容宽度(最多 360px)参与测量,而不是按行宽比例,因此长模型名在测量中保持可见。若连图标也放不下,仍允许换行。 上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。 @@ -82,7 +82,7 @@ composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输 关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 -root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。不逐项测量标签。至少保留一个优先展示的外部标签,长标题截断并通过提示框提供全名。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。 +root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。每个标签按字符宽度估算文本宽度;只有估算结果超过 88px 单元格时,才通过提示框提供全名。至少保留一个优先展示的外部标签。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index ca25595c40..caec4849b4 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -126,7 +126,7 @@ export type MessageImageLoader = ((attachment: ImageAttachmentRef) => Promise { - return entry.binding.session.readAttachment(attachment.attachmentId) + return entry.binding.session.readAttachment(attachment) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) this.assertLive(key, entry) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 3089b083df..896d953c9f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -43,6 +43,29 @@ function ViewsMoreMenu({ label, current, entries, onOpen, onPinnedChange, render ) } +/** Approximate 13px text advance: full-width glyphs take one em, other glyphs about half. */ +const WIDE_GLYPH = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/u +const WIDE_GLYPH_WIDTH = 13 +const NARROW_GLYPH_WIDTH = 7 +/** Mirrors the `.tabLabel` max-width in `ConversationRoot.module.css`. */ +const TAB_LABEL_MAX_WIDTH = 88 + +/** Estimate whether a View tab label exceeds its 88px cell. */ +function labelNeedsTooltip(label: string): boolean { + let width = 0 + for (const glyph of label) width += WIDE_GLYPH.test(glyph) ? WIDE_GLYPH_WIDTH : NARROW_GLYPH_WIDTH + return width > TAB_LABEL_MAX_WIDTH +} + +/** One View tab label; its full-name tooltip is offered only when the text is expected to overflow. */ +function ViewTabLabel({ label }: { readonly label: string }) { + return ( + + {label} + + ) +} + interface Breadcrumb { readonly id: SessionId readonly displayTitle: string @@ -205,7 +228,7 @@ export function ConversationSessionHeader({ className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)} onClick={() => { selectView(viewTab.id) }} > - {viewTab.label} + ))} diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index bdf7c0f0f1..6da7a7eee2 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -9,9 +9,10 @@ describe('HistoricalImageCache', () => { it('invalidates a pending image load when its Session binding is released', async () => { const read = Promise.withResolvers>>() const runtime = await SlotTestRuntime.create() + const load = vi.fn(() => read.promise) const sessionId = await runtime.sessions.add({ id: 's1', - session: { readAttachment: () => read.promise }, + session: { readAttachment: load }, }) const reference = runtime.sessions.retain(sessionId) await reference.ready @@ -21,6 +22,7 @@ describe('HistoricalImageCache', () => { } as const const pending = cache.resolve(sessionId, attachment) + expect(load).toHaveBeenCalledExactlyOnceWith(attachment) reference.release() await runtime.flush() read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index bda16c8a08..8d6084b1fe 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createContext, useContext, type ReactNode } from 'react' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import type { Context } from '@deepseek-ai/cordis' import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -735,6 +735,29 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByRole('menu')).toBeNull() }) + it('offers a View tab tooltip only when the label is expected to overflow', () => { + const b = mount(sessionSnapshotOf(), undefined, undefined, { + viewTabs: [ + { id: 'chat', label: 'Chat' }, + { id: 'short', label: '轨迹' }, + { id: 'long-cjk', label: '很长的视图名称' }, + { id: 'long-latin', label: 'A very long view name' }, + ], + headerOnly: true, + }) + const tooltipFor = (label: string): string | null => { + const span = within(b.view.getByRole('tab', { name: label })).getByText(label) + fireEvent.pointerEnter(span) + const tooltip = screen.queryByRole('tooltip', { hidden: true })?.textContent ?? null + fireEvent.pointerLeave(span) + return tooltip + } + expect(tooltipFor('Chat')).toBeNull() + expect(tooltipFor('轨迹')).toBeNull() + expect(tooltipFor('很长的视图名称')).toBe('很长的视图名称') + expect(tooltipFor('A very long view name')).toBe('A very long view name') + }) + it('reuses the width budget on View roster and selection changes without a window resize listener', () => { let width = 480 const readWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width) diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 3dafc30f52..6bff57dde1 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -18,8 +18,8 @@ en: 718f52cfce426009 zh: e8282e2e3713d242 /deepseek-ai-dsh-client-ui-dockkit/embedding-it: - en: c4ba08038bfa6f30 - zh: 95db30618b52babe + en: 87fab28e0608044e + zh: 2d5f80a367f1852b /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping: en: cf2d00ce2b7d5719 zh: 5510420f8872e23a diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index bc29b54c08..12f5b74938 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -64,7 +64,9 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp `DockLayout`, used by the Sidebar, keeps each tab in a stable DOM cell across selection, pane moves and floating. It accepts one docked pane or two horizontal panes; CSS Grid resolves their widths. `keepMounted(tab)` retains visited bodies and `active` controls Session visibility without reparenting content. -`DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. Both float renderers keep the header at least 20px below the Windows caption (20px below the viewport top in native fullscreen), including newly opened and restored panels. Dragging and resizing start at the displayed position. +`DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. At rest both float renderers keep the header at least 20px below the Windows caption (20px below the viewport top in native fullscreen), including newly opened and restored panels. Dragging and resizing start at the displayed position. + +Floating panels stay inside the viewport. Each draws at its stored rectangle projected into the viewport, so a shrinking window pushes it in without recording an operation and a growing window returns it toward the stored position; a panel larger than the viewport draws at the viewport's size. A move follows the pointer one to one inside the viewport and resists past any edge, the top included, drawing at most 120px beyond it; its release records one `moveFloat` with the nearest legal rectangle, or only a raise when that is the legal rectangle of the press, then springs the panel there from where it was drawn, carrying the drawn velocity, staying within those 120px, and overshooting slightly inward. A press on the header or the resize corner during that return picks the panel up where it is drawn; a resize from there keeps its origin and springs it inside on release. A resize keeps the corner inside the viewport. At rest the stylesheet clamps every panel's position and size into the viewport once more, so a stale viewport reading still leaves it inside. Under `prefers-reduced-motion: reduce` a move clamps at the edge and nothing animates. ## Interaction rules worth keeping diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 15c5d332d9..06b1a593f3 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -64,7 +64,9 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间,把每个 tab 保留在稳定的 DOM 容器中。它接受一个停靠格或左右两个格,由 CSS Grid 计算宽度。`keepMounted(tab)` 保留已访问正文,`active` 控制 Session 可见性,不改变内容的父子关系。 -`DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 两种浮窗渲染器都让标题栏与 Windows 顶栏至少间隔 20px(原生全屏时距视口顶部 20px),新建和恢复的浮窗均适用。拖动和调整大小从实际显示位置开始。 +`DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 静止时两种浮窗渲染器都让标题栏与 Windows 顶栏至少间隔 20px(原生全屏时距视口顶部 20px),新建和恢复的浮窗均适用。拖动和调整大小从实际显示位置开始。 + +浮窗始终留在视口内。每个浮窗绘制在其存储矩形投影到视口后的位置,因此窗口缩小时浮窗被推入且不记录操作,窗口变大时浮窗朝存储位置回移;大于视口的浮窗按视口尺寸绘制。移动时浮窗在视口内一比一跟随指针,越过任一边界(包括顶部)后受到阻力,最多越过边界 120px;松手时记录一次带最近合法矩形的 `moveFloat`,若该矩形就是按下时的合法矩形则只提升浮窗,再让浮窗从绘制位置以弹簧回到该矩形,携带绘制速度、不超出这 120px,并略微向内过冲。回弹过程中按下标题栏或缩放角都会从绘制位置接住浮窗;从那里缩放时保留原点,松手后再以弹簧收回视口。调整大小时右下角保持在视口内。静止时样式表会再次把每个浮窗的位置和尺寸钳制在视口内,因此即使视口读数过期,浮窗仍在视口内。在 `prefers-reduced-motion: reduce` 下,移动在边界处钳制且不播放动画。 ## 值得保留的交互规则 diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index dfe028993b..acbce6e88a 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -13,16 +13,23 @@ * only `state` and the outward contracts. An embedder may portal it anywhere, * and nothing here assumes the docked tree is an ancestor or even present. */ -import { useState } from 'react' -import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' +import { useEffect, useRef, useState } from 'react' +import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconCloseOutlineRegular, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' import type { FloatRect, LayoutState, PaneId, TabId, TabRecord } from '../contract/types.ts' import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' -import { movedRect, resizedRect } from '../engine/geometry.ts' +import { + boundedResize, FLOAT_RESISTANCE, legalFloatRect, movedRect, resistedRect, resizedRect, unresistedRect, type FloatBounds, +} from '../engine/geometry.ts' +import { + launchVelocity, SPRING_DEADLINE_MS, SPRING_MAX_FRAME_MS, SPRING_MAX_VELOCITY, SPRING_STALL_MS, springSettled, stepSpring, + type SpringAxis, +} from '../engine/spring.ts' import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts' import { useGesture } from './pointer.ts' +import { TabBody } from './TabBody.tsx' import { TabTitle } from './TabTitle.tsx' import css from './dockkit.module.css' @@ -46,7 +53,7 @@ interface FloatDrag { readonly rect: FloatRect } -/** The rectangle a gesture has reached. */ +/** The rectangle the pointer travel asks for, before the viewport has its say. */ function draggedRect(drag: FloatDrag, x: number, y: number): FloatRect { const dx = x - drag.originX const dy = y - drag.originY @@ -65,47 +72,238 @@ function raised(state: LayoutState, paneId: PaneId): boolean { return state.activePaneId === paneId && state.floats.at(-1) === paneId } +/** + * The viewport region panels stay inside. It reads the window size and the + * root's caption attributes only, so starting a gesture never forces layout. + */ +function floatBounds(): FloatBounds { + const root = document.documentElement + const windows = root.hasAttribute('data-windows-titlebar') + // Desktop publishes the caption height in pixels on the root's inline style; + // a root without it keeps the 20px floor. + const caption = windows && !root.hasAttribute('data-fullscreen') + ? Number.parseFloat(root.style.getPropertyValue('--dsh-windows-titlebar-height')) : 0 + return { top: windows ? (Number.isFinite(caption) ? caption : 0) + 20 : 0, width: window.innerWidth, height: window.innerHeight } +} + +/** Whether the platform asks for reduced motion; environments without `matchMedia` (jsdom) report no preference. */ +function reducedMotion(): boolean { + return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches +} + +/** How much recent drawing history the release velocity is measured over. */ +const VELOCITY_WINDOW_MS = 100 + +/** One drawn panel position and when it was drawn. */ +interface DrawnSample { + readonly t: number + readonly x: number + readonly y: number +} + +/** Append a drawn position and drop the samples more than `VELOCITY_WINDOW_MS` older than it. */ +function sampleDrawn(samples: DrawnSample[], t: number, rect: FloatRect): void { + samples.push({ t, x: rect.x, y: rect.y }) + while (t - (samples[0] as DrawnSample).t > VELOCITY_WINDOW_MS) samples.shift() +} + +/** + * Drawn panel velocity over the kept samples, capped per axis, in pixels per + * second. It follows the drawn panel rather than the pointer, so the speed stays + * continuous across a release where the resistance slows the panel. + */ +function releaseVelocity(samples: readonly DrawnSample[]): { readonly x: number; readonly y: number } { + const first = samples[0] as DrawnSample + const last = samples[samples.length - 1] as DrawnSample + if (last.t === first.t) return { x: 0, y: 0 } + const rate = (travel: number): number => + Math.max(-SPRING_MAX_VELOCITY, Math.min(SPRING_MAX_VELOCITY, travel / (last.t - first.t) * 1000)) + return { x: rate(last.x - first.x), y: rate(last.y - first.y) } +} + +/** A panel springing back inside the viewport after a release past an edge. */ +interface Settling { + readonly paneId: PaneId + readonly target: FloatRect + readonly started: number + x: SpringAxis + y: SpringAxis + last: number + frame: number +} + +/** + * Inline position of a floating panel. A gesture or return draws it exactly at + * `rect`. At rest the panel only publishes `rect` to the `.float` rule, which + * clamps it into the viewport and below the caption floor, so a stale viewport + * reading in `useFloatGestures` still leaves the panel inside. + * @param rect - the rectangle to draw. + * @param lifted - whether a gesture or return is drawing the panel. + * @returns the panel's inline position and size. + */ +export function floatStyle(rect: FloatRect, lifted: boolean): CSSProperties { + if (lifted) return { left: rect.x, top: rect.y, width: rect.width, height: rect.height } + return { + '--dsh-dockkit-float-x': `${rect.x}px`, + '--dsh-dockkit-float-y': `${rect.y}px`, + '--dsh-dockkit-float-width': `${rect.width}px`, + '--dsh-dockkit-float-height': `${rect.height}px`, + } as CSSProperties +} + /** * Shared move/resize gestures; changing layout mode never owns the content DOM. + * Panels draw at their stored rectangle projected into the viewport, so a + * shrinking window pushes them in without recording anything. A move past an + * edge draws with resistance; its release records the legal rectangle and the + * panel springs there from where it was drawn. * @param state - committed layout and float stacking order. * @param intents - gesture settlements. - * @returns the current preview and focus/gesture callbacks. + * @returns the current preview, the rectangle to draw per panel, and focus/gesture callbacks. */ export function useFloatGestures(state: LayoutState, intents: DockIntents) { const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined) + const [bounds, setBounds] = useState(floatBounds) + // The bounds a gesture reads: re-read at each press and on each window resize. + const area = useRef(bounds) + // The committed layout, for a return to notice its panel's record changing. + const latest = useRef(state) + latest.current = state + const settling = useRef(undefined) const begin = useGesture(() => { setPreview(undefined) }) + /** Stop a running return where it is; reports whether one was running. */ + const cancelSettling = useRef((): boolean => { + const running = settling.current + if (running === undefined) return false + cancelAnimationFrame(running.frame) + settling.current = undefined + return true + }).current + + useEffect(() => { + const resize = (): void => { + if (cancelSettling()) setPreview(undefined) + area.current = floatBounds() + setBounds(area.current) + } + window.addEventListener('resize', resize) + return () => { + window.removeEventListener('resize', resize) + cancelSettling() + } + }, [cancelSettling]) + + /** + * One animation frame of a return: advance the spring, or end it at its + * target. It also ends once the panel's record no longer projects to the + * target, as after an undo, a dock or a close, so the release's `moveFloat` + * or `resizeFloat` must reach `state` before the first frame. + */ + const frame = (): void => { + const running = settling.current as Settling + const now = performance.now() + const gap = now - running.last + running.last = now + const layout = latest.current + const recorded = layout.floats.includes(running.paneId) + && sameRect(legalFloatRect(floatRect(getPane(layout, running.paneId)), area.current), running.target) + if (recorded && gap <= SPRING_STALL_MS && now - running.started < SPRING_DEADLINE_MS) { + const dt = Math.min(gap, SPRING_MAX_FRAME_MS) / 1000 + running.x = stepSpring(running.x, running.target.x, dt) + running.y = stepSpring(running.y, running.target.y, dt) + if (!springSettled(running.x, running.target.x) || !springSettled(running.y, running.target.y)) { + setPreview({ paneId: running.paneId, rect: { ...running.target, x: running.x.position, y: running.y.position } }) + running.frame = requestAnimationFrame(frame) + return + } + } + settling.current = undefined + setPreview(undefined) + } + + /** Spring a panel from where it was drawn to its recorded rectangle, never farther than `FLOAT_RESISTANCE` outside it. */ + const settle = (paneId: PaneId, from: FloatRect, target: FloatRect, velocity: { x: number; y: number }): void => { + const axis = (position: number, goal: number, speed: number): SpringAxis => + ({ position, velocity: launchVelocity(position, goal, speed, FLOAT_RESISTANCE) }) + const now = performance.now() + settling.current = { + paneId, target, started: now, last: now, + x: axis(from.x, target.x, velocity.x), y: axis(from.y, target.y, velocity.y), + frame: requestAnimationFrame(frame), + } + setPreview({ paneId, rect: from }) + } + /** Focus and raise a panel from a press or click on it, unless it is raised already. */ const raise = (paneId: PaneId): void => { if (raised(state, paneId)) return intents.focusPane(paneId) } - /** Start a move or resize from a press on the panel's grip or corner; a release that moved nothing is a click. */ + /** + * Where a panel draws: its gesture or return preview, otherwise its stored + * rectangle projected into the viewport. + * @param paneId - the floating pane. + * @param stored - its recorded rectangle. + * @returns the rectangle to draw. + */ + const rectOf = (paneId: PaneId, stored: FloatRect): FloatRect => + preview?.paneId === paneId ? preview.rect : legalFloatRect(stored, bounds) + + /** + * Start a move or resize from a press on the panel's grip or corner. A release + * that reaches the same legal rectangle as the press is a click and only raises, + * also for a panel drawn projected from its record. + */ const drag = (mode: 'move' | 'resize', paneId: PaneId, event: ReactPointerEvent): void => { // The press stops here: the panel's own press-to-focus would record a focus // entry before the drag's, and the release below decides which one it is. event.stopPropagation() - const root = document.documentElement - const windows = root.hasAttribute('data-windows-titlebar') - // Desktop publishes the caption height in pixels on the root's inline style. - const caption = windows && !root.hasAttribute('data-fullscreen') - ? Number.parseFloat(root.style.getPropertyValue('--dsh-windows-titlebar-height')) : 0 - const saved = floatRect(getPane(state, paneId)) - const rect = { ...saved, y: Math.max(saved.y, windows ? caption + 20 : 0) } + // A press during its own return takes the panel from where it is drawn; + // another panel's return ends at its target. + const drawn = settling.current?.paneId === paneId ? preview?.rect : undefined + if (cancelSettling() && drawn === undefined) setPreview(undefined) + area.current = floatBounds() + const reduce = reducedMotion() + const shown = drawn ?? legalFloatRect(floatRect(getPane(state, paneId)), area.current) + const rect = mode === 'move' && !reduce ? unresistedRect(shown, area.current) : shown const start: FloatDrag = { mode, originX: event.clientX, originY: event.clientY, rect } + /** The rectangle to draw for a pointer position, and the legal one a release there records. */ + const reached = (x: number, y: number): { readonly drawn: FloatRect; readonly legal: FloatRect } => { + const desired = draggedRect(start, x, y) + if (mode === 'resize') { + // A resize keeps its origin, which lies outside the viewport when the + // press caught a return or the viewport shrank mid-gesture; the + // release records the legal rectangle and springs the panel there. + const resized = boundedResize(desired, area.current, FLOAT_MIN_SIZE) + const legal = legalFloatRect(resized, area.current) + return { drawn: reduce ? legal : resized, legal } + } + const legal = legalFloatRect(desired, area.current) + return { drawn: reduce ? legal : resistedRect(desired, area.current), legal } + } + const pressed = reached(event.clientX, event.clientY) + const samples: DrawnSample[] = [] + sampleDrawn(samples, performance.now(), pressed.drawn) begin(event.currentTarget, event.pointerId, { - move: (moved) => { setPreview({ paneId, rect: draggedRect(start, moved.clientX, moved.clientY) }) }, + move: (moved) => { + const { drawn } = reached(moved.clientX, moved.clientY) + sampleDrawn(samples, performance.now(), drawn) + setPreview({ paneId, rect: drawn }) + }, up: (released) => { - const rect = draggedRect(start, released.clientX, released.clientY) - if (sameRect(rect, start.rect)) raise(paneId) - else if (mode === 'move') intents.moveFloat(paneId, rect.x, rect.y) - else intents.resizeFloat(paneId, rect) + const { drawn, legal } = reached(released.clientX, released.clientY) + sampleDrawn(samples, performance.now(), drawn) + if (sameRect(legal, pressed.legal)) raise(paneId) + else if (mode === 'move') intents.moveFloat(paneId, legal.x, legal.y) + else intents.resizeFloat(paneId, legal) + if (!sameRect(drawn, legal)) settle(paneId, drawn, legal, releaseVelocity(samples)) }, }) } - return { preview, raise, drag } + return { preview, raise, drag, rectOf } } /** @@ -151,7 +349,7 @@ export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canC /** Every floating panel, in z order. */ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode { - const { preview, raise, drag } = useFloatGestures(state, intents) + const { preview, raise, drag, rectOf } = useFloatGestures(state, intents) return ( <> @@ -161,7 +359,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, // A panel mid-gesture draws where the pointer has taken it and on top, // as the operation its release records will leave it. const lifted = preview?.paneId === paneId ? preview.rect : undefined - const live = lifted ?? floatRect(pane) + const live = rectOf(paneId, floatRect(pane)) return (
{ raise(paneId) }} > -
{renderTab(tab)}
+
{ element.removeEventListener('focus', focus, true) } }, [visible, floating, floats, pane.id, state.activePaneId, callbacks]) const lifted = floating && floats.preview?.paneId === pane.id ? floats.preview.rect : undefined - const rect = floating ? lifted ?? floatRect(pane) : undefined + const rect = floating ? floats.rectOf(pane.id, floatRect(pane)) : undefined const depth = lifted === undefined ? state.floats.indexOf(pane.id) + 1 : state.floats.length + 1 return (