Merge master into DSH 0.2.1-rc.4 synchronization branch

This commit is contained in:
imccyu
2026-10-26 16:18:13 +08:00
642 changed files with 27836 additions and 4077 deletions
@@ -6,8 +6,8 @@
en: 982ac05aa02da2f0
zh: 225f89c994efb1c4
/agent-note-awaited-per-agent-initialization/problem:
en: 03e6b75365b29406
zh: cc880a33cf2303d0
en: 6e9393e78ff71286
zh: b931a7e606cad37e
/agent-note-awaited-per-agent-initialization/decision:
en: 48cdb570d3feb222
zh: 904a1169540e3111
@@ -15,5 +15,5 @@
en: 466a9ebbb13f68c6
zh: ab88fb34786d2c2b
/agent-note-awaited-per-agent-initialization/consequences:
en: d8cfe8d88ccacfc8
zh: 8d2aa21408e6e40e
en: 3fc2b36a94ace3ba
zh: 65bb386b89ff89ce
@@ -6,7 +6,7 @@ English | [中文](2026-09-09-awaited-agent-creation.zh.md)
## Problem
Shared presets install tools and prompt sections separately for each Agent. That installation can await plugin activation, and external SessionStart hooks can produce context asynchronously. A creator must know that these contributions have finished before the Agent's first model request.
Agent-specific plugins can install tools asynchronously, and external SessionStart hooks can produce context asynchronously. A creator must know that these contributions have finished before the Agent's first model request.
## Decision
@@ -28,4 +28,6 @@ This decision owns asynchronous creation timing. The [scope runtime reference](.
Creation latency includes asynchronous plugin and SessionStart work. Initializer failures become caller-visible creation failures, and cancellation relies on listeners settling cooperatively. Notifications already delivered cannot be undone; rollback pairs them with disposal notifications.
Scoped installer tests verify rollback, lifecycle tests verify ordered completion and cancellation, and the SDK serial-created scenario verifies that asynchronous prompt context reaches the first request. Hook tests cover awaited context injection and subprocess disposal.
Lifecycle tests verify initialization rollback, ordered completion and cancellation, and the SDK serial-created scenario verifies that asynchronous prompt context reaches the first request. Hook tests cover awaited context injection and subprocess disposal.
Delegation tools use [shared preset registrations](../bug-fix/2026-10-25-shared-preset-delegation-tools.md); they do not require per-Agent installation. This event still owns asynchronous Agent initialization.
@@ -6,7 +6,7 @@ Status: implemented
## 问题
共享 preset 为每个 Agent(智能体)分别安装工具与提示词段。安装可能要等待插件激活,外部 SessionStart 钩子也可能异步产生上下文。创建方必须知道这些贡献已完成,才能允许 Agent 发起首次模型请求。
针对单个 Agent(智能体)的插件可能异步安装工具,外部 SessionStart 钩子也可能异步产生上下文。创建方必须知道这些贡献已完成,才能允许 Agent 发起首次模型请求。
## 决策
@@ -28,4 +28,6 @@ AgentLoop 在 setup 与创建分发期间保留现有的维护活动。初始化
创建延迟包括异步插件与 SessionStart 工作。初始化器失败成为调用方可见的创建失败,取消依赖监听器协作结算。已经送达的通知无法撤回;回滚会为其配对销毁通知。
作用域安装器测试验证回滚,生命周期测试验证有序完成与取消,SDK serial-created 场景验证异步提示词上下文进入首次请求。钩子测试覆盖需等待的上下文注入与子进程 dispose。
生命周期测试验证初始化回滚、有序完成与取消,SDK serial-created 场景验证异步提示词上下文进入首次请求。钩子测试覆盖需等待的上下文注入与子进程 dispose。
委派工具使用[预设共享注册](../bug-fix/2026-10-25-shared-preset-delegation-tools.zh.md),无须按 Agent 安装。此事件仍负责异步 Agent 初始化。
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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 和程序指引跟随所选模式。
@@ -9,11 +9,11 @@
en: b32a7ce6483b9b48
zh: ec0938ad032b99d8
/agent-note-application-consent-for-native-computer-use/decision:
en: 28ec4cc587ba487b
zh: 8b89bbdff8eff6eb
en: b34d24e39879277e
zh: 03363665d4682ddb
/agent-note-application-consent-for-native-computer-use/alternatives-considered:
en: 9f41ecc5e34efe17
zh: b30ba6dae25b20b2
en: 18b2c124f1d4e601
zh: 621a6c9512d5612b
/agent-note-application-consent-for-native-computer-use/consequences:
en: 4bda3366af65250a
zh: 7c83561b4db07284
@@ -14,9 +14,9 @@ The CUA executor owns application consent for every object-API and native-tool d
System-permission buttons call the native helper on the DSH host and remain available to remote browsers. The [usage documentation](../../../../packages/experimental/cua/README.md#use-this-package) explains where to complete authorization; the page has no persistent remote-access notice.
A saved identity combines the canonical executable path with a verified designated signing requirement, represented by a SHA-256 digest. The path prevents authorization from following a copied application, and the signing requirement distinguishes replacements while allowing matching signed updates. Unverifiable and ad-hoc applications receive session access only, tied to a process instance. Labels do not establish identity.
Application identity is owned by [bundle-ID consent](2026-10-25-cua-bundle-id-consent.md), which supersedes this note's original path/signature choice. That original choice prevented copied or replaced applications from inheriting access, at the cost of separate grants per installation and no saved grants for unsigned applications. Enforcement and human decision ownership remain as described here.
Cold application selection prepares an executable identity and handle without launching. The same consent decision guards the private launch continuation, and the helper checks the executable again before launch and binds the resulting process afterward. Signed selections retain the normal designated-requirement identity; an unverifiable executable uses a content identity for this pending selection and cannot create a saved grant. Preparing metadata before asking avoids starting an application the user refuses.
Cold application selection prepares an executable identity and handle without launching. The same consent decision guards the private launch continuation, and the helper checks the executable again before launch and binds the resulting process afterward. Selections with a bundle identifier retain that identity after launch; an application without one uses its canonical executable path before and after launch and can save that grant. Preparing metadata before asking avoids starting an application the user refuses.
Conversation decisions survive provider disposal and DSH restart according to [profile consent storage](2026-10-19-cua-profile-consent-storage.md). That decision owns persistence, migration and cross-process revocation. The executor continues to serialize application selections within a conversation, refuse repeated prompts after denial, and give an explicit long-term grant precedence over conversation refusals.
@@ -26,7 +26,7 @@ Revocation invalidates the Agent connections that retain the application's handl
**Trust model instructions or hide controls.** Retained objects and direct tool calls can still reach native operations. The executor must enforce the decision at dispatch.
**Persist application names or unsigned paths.** A replacement can reuse both. Durable grants require a verifiable signing identity; session access supports unsigned development applications without that promise.
**Persist application names or unsigned paths.** A replacement can reuse both. [Bundle-ID consent](2026-10-25-cua-bundle-id-consent.md) owns the identity choices and the decision to share permission between installations.
**Add an approval database or Session control stream.** User Questions already owns interactive answers. The existing domain service provides durable state without introducing a dedicated database or Session control stream; storage tradeoffs are owned by the profile consent decision.
@@ -14,9 +14,9 @@ CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。
系统权限按钮调用运行 DSH 的电脑上的原生 helper,远程访问时仍可使用。授权地点写在[使用说明](../../../../packages/experimental/cua/README.zh.md#use-this-package)中,页面不常驻远程访问提示。
长期身份由规范化可执行文件路径和经过验证的签名指定要求组成,以 SHA-256 摘要表示。路径阻止许可跟随应用副本,签名要求区分替换程序,并允许符合相同签名要求的更新。签名不可验证或使用 ad-hoc 签名的应用只能获得会话授权,许可绑定进程实例。显示名称不用于确定身份。
应用身份由[Bundle ID 授权](2026-10-25-cua-bundle-id-consent.zh.md)负责,取代本记录原先的路径和签名选择。原选择防止应用副本或替换程序继承许可,代价是各安装副本必须分别授权,未签名应用无法保存长期许可。执行检查和用户决定的所有权仍由本记录规定。
选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。已签名应用沿用签名指定要求身份;签名不可验证的程序使用本次待启动选择的内容身份,不能生成长期许可。先准备元数据再询问,可以避免启动用户拒绝的应用。
选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。有 Bundle ID 的应用在启动后沿用该身份;没有 Bundle ID 的程序在启动前后使用规范化可执行路径,并可保存该许可。先准备元数据再询问,可以避免启动用户拒绝的应用。
会话决定按[profile 授权存储](2026-10-19-cua-profile-consent-storage.zh.md)的规则,在提供方卸载和 DSH 重启后保留。该记录负责持久化、迁移和跨进程撤销。执行器继续将同一对话对同一应用的选择串行化,拒绝后不再次询问,显式长期许可优先于会话拒绝。
@@ -26,7 +26,7 @@ CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。
**依赖模型指引或隐藏控件。** 保留的对象和直接工具调用仍能到达原生操作。执行器必须在派发时检查用户决定。
**保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;会话授权支持未签名的开发应用,但不承诺跨进程复用。
**保存应用名称或未签名路径。** 替换程序可以复用二者。身份选择和在安装副本之间共用许可的决定由[Bundle ID 授权](2026-10-25-cua-bundle-id-consent.zh.md)负责。
**增加审批数据库或 Session 控制流。** User Questions 已经负责交互式回答。现有域服务提供持久状态,无须增加专用数据库或 Session 控制流;存储取舍由 profile 授权存储决策负责。
@@ -9,8 +9,8 @@
en: f2c70036a57ddb41
zh: 9bbbf3dd09d486f5
/agent-note-durable-computer-use-consent-within-one-profile/decision:
en: 1df102f3d0440a6a
zh: 3456af8e552729a3
en: 11622523090f5d88
zh: 1d0f8ad94645f069
/agent-note-durable-computer-use-consent-within-one-profile/alternatives-considered:
en: 2af74afa3ecaf3b9
zh: 96f7f43c5d1150df
@@ -10,7 +10,7 @@ Application grants stored as plugin configuration make each authorization change
## Decision
The CUA owner uses the existing domain storage service, with one domain per canonical profile directory. It stores long-term grants and conversation decisions by native application identity. Conversation IDs scope permission without turning it into permission for every conversation. The native identity policy remains owned by [application consent](2026-09-26-cua-application-consent.md); this note replaces only that note's storage and restart-lifetime choices.
The CUA owner uses the existing domain storage service, with one domain per canonical profile directory. It stores long-term grants and conversation decisions by native application identity. Conversation IDs scope permission without turning it into permission for every conversation. The native identity policy remains owned by [bundle-ID consent](2026-10-25-cua-bundle-id-consent.md); this note replaces only that note's storage and restart-lifetime choices.
Every operation opens fresh domain state while holding the profile's existing file-lock primitive and closes the domain before releasing the lock. Native dispatch and revocation use a separate lock for the selected application. Storage reads and unrelated applications remain available during a native call. Revocation cancels this process's affected work first, then commits after other processes' already-admitted operations have finished. A completed revocation therefore leaves no operation using the old authority. App-wide revocation generations and per-conversation decision revisions prevent an old handle or delayed answer from restoring revoked access.
@@ -10,7 +10,7 @@ Status: implemented
## 决策
CUA 使用现有域存储服务,每个 profile 的真实目录对应独立存储域,按本机应用身份保存长期许可和会话决定。对话 ID 限定权限范围,不把本会话许可扩展为所有对话的许可。本机身份规则仍由[应用授权](2026-09-26-cua-application-consent.zh.md)负责,本记录仅替代其中的存储和重启后有效期选择。
CUA 使用现有域存储服务,每个 profile 的真实目录对应独立存储域,按本机应用身份保存长期许可和会话决定。对话 ID 限定权限范围,不把本会话许可扩展为所有对话的许可。本机身份规则仍由[Bundle ID 授权](2026-10-25-cua-bundle-id-consent.zh.md)负责,本记录仅替代其中的存储和重启后有效期选择。
每个操作使用现有文件锁,在 profile 锁内打开并读取最新存储状态,关闭域后才释放锁。原生操作和撤销使用单独的目标应用锁;原生调用期间仍可读取存储或访问其他应用。撤销先取消本进程中受影响的工作,再等待其他进程已获准执行的操作结束后提交。因此,撤销完成后不会留下仍使用旧授权的操作。应用级撤销代次和各对话的决定版本,阻止旧句柄或迟到回答恢复被撤销的访问权限。
@@ -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
@@ -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.
@@ -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 包含这些依赖。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-25-cua-bundle-id-consent.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-25-cua-bundle-id-consent.md
/agent-note-macos-consent-shared-by-bundle-identifier:
en: 41cd1b8e2629e049
zh: f1bcef434b6d247f
/agent-note-macos-consent-shared-by-bundle-identifier/problem:
en: bb387fc89c1c578c
zh: 947456baea45cc95
/agent-note-macos-consent-shared-by-bundle-identifier/decision:
en: 7b1bbab1f95b7610
zh: 434e7a3fba81174b
/agent-note-macos-consent-shared-by-bundle-identifier/alternatives-considered:
en: 637b1fd2bafb1b28
zh: 06d882818ffaf800
/agent-note-macos-consent-shared-by-bundle-identifier/consequences:
en: 1af6d29a57ff86e2
zh: 32517607b2fc935f
@@ -0,0 +1,33 @@
# Agent Note: macOS consent shared by bundle identifier
Status: implemented
English | [中文](2026-10-25-cua-bundle-id-consent.zh.md)
## Problem
Users who install multiple versions of one application want one permission decision for that software. Executable paths split those decisions, and signing checks exclude unsigned development applications from saved access.
## Decision
macOS application consent uses the exact, case-sensitive native bundle identifier. The helper hashes `macos:bundle-id:` followed by that identifier into `CuaApplicationId`. When a bundle identifier exists, installation paths, versions and signatures do not distinguish grants. Otherwise, the helper hashes `macos:executable-path:` followed by the canonical executable path. Both identities support saved access; path identities survive restart and in-place updates, while a different path requires a new decision. Conversation decisions and saved grants retain the scopes and revocation behavior of [profile consent storage](2026-10-19-cua-profile-consent-storage.md).
This supersedes the executable/signature identity choice in [application consent](2026-09-26-cua-application-consent.md); that note still owns enforcement and human decisions. The running process path comes from the operating system. A valid runtime bundle identifier does not depend on path-query success. Missing paths omit optional grant metadata; they never substitute a cached catalog path. When both bundle identity and process path are unavailable, consent fails with the path query cause. Process identity must still be verified, and any available process path must match the selected target. Missing catalog path metadata does not block consent when the process path is available. If the runtime bundle identifier is unavailable, a reported application bundle can supply it only when its main executable matches the process path; otherwise the executable path identifies the grant. Native process, executable-location and window checks continue to bind each handle to its selected target. Launch checks reread the bundle identifier and executable path from disk; they do not fingerprint executable content. Replacing bytes at the same path while retaining the identifier or path identity therefore retains consent, including during an approval wait.
The identity prefixes separate new grants from old path/signature, process-instance and executable-content digests. Old grants remain removable but do not grant access to new bundle identities; users decide again before permission covers other installations.
Apple’s [PPPC configuration](https://support.apple.com/guide/deployment/privacy-preferences-policy-control-payload-dep38df53c2a/web) supports bundle-ID or path selectors and separately requires a code-signing requirement. [TN3127](https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements) explains that designated requirements track code identity across versions, while unsigned and ad-hoc code cannot provide the same stability. DSH adopts the selector alternatives for application management without requiring publisher authentication.
## Alternatives considered
**Bundle identifier and path.** This distinguishes installed copies but requires separate decisions for the multiple versions the user wants to authorize together.
**Bundle identifiers only.** Missing identifiers would exclude otherwise usable applications from saved access. Canonical executable paths supply a stable fallback without using a PID or content hash.
**Verified signing identity.** This distinguishes publishers but denies saved access when signing is absent or cannot be verified. Publisher authentication is outside this application-management policy.
## Consequences
Another program declaring the same bundle identifier or replacing an executable at its approved fallback path inherits consent, including a program created or changed through separately authorized model tools. The worst consequence is observing or controlling an application that the user did not approve separately, within its available data and capabilities. The model cannot supply the authorization identity through the CUA request; the native helper reads it from the selected application. Different bundle identifiers require different decisions, even when users consider them versions of one product.
Revocation applies to every installation sharing the identifier. Tests exercise unsigned copies, different identifiers, missing identifiers with persistent path consent, process restarts, symlink aliases, pending and running identity agreement, shared handle revocation and refusal to widen old grants.
@@ -0,0 +1,33 @@
# Agent Note: 按 Bundle ID 共用 macOS 应用授权
Status: implemented
[English](2026-10-25-cua-bundle-id-consent.md) | 中文
## 问题
用户安装同一应用的多个版本时,希望对这个软件只做一次权限决定。可执行文件路径会拆分这些决定,签名检查也会让未签名的开发版应用无法保存长期授权。
## 决策
macOS 应用授权使用原生应用提供的、区分大小写的完整 Bundle ID。helper 将 `macos:bundle-id:` 和该标识连接后计算摘要,生成 `CuaApplicationId`。有 Bundle ID 时,安装路径、版本和签名不区分授权。否则,helper 对 `macos:executable-path:` 与规范化可执行路径计算摘要。两种身份都支持长期许可;路径身份在重启和原地更新后保持不变,另一个路径需要新的决定。会话决定和长期许可保留[profile 授权存储](2026-10-19-cua-profile-consent-storage.zh.md)规定的范围和撤销行为。
本决策取代[应用授权](2026-09-26-cua-application-consent.zh.md)中的可执行文件和签名身份选择,原记录仍负责执行检查与用户决定。运行进程的可执行路径由操作系统提供。有效的运行时 Bundle ID 不依赖路径查询成功。无法取得路径时省略授权记录中的可选路径信息,不用缓存的应用目录路径代替。Bundle ID 和实际路径均不可用时,授权失败并报告路径查询原因。进程身份仍须验证,可取得的实际路径仍须与所选目标一致。只要能取得实际进程路径,应用目录信息中缺少路径不会阻止授权。运行时无法取得 Bundle ID 时,仅在其报告的应用 bundle 主可执行文件与进程路径一致时读取该标识,否则按可执行路径识别授权。原生进程、可执行文件位置和窗口检查继续把每个句柄绑定到所选目标。启动检查从磁盘重新读取 Bundle ID 和可执行路径,但不计算可执行内容指纹。因此,在相同路径下替换内容且保留相同标识或路径身份时,许可仍会沿用,包括等待用户批准期间的替换。
身份前缀将新授权与旧的路径和签名、进程实例、可执行内容摘要分开。旧授权仍可撤销,但不能授予新 Bundle ID 身份访问权限;许可覆盖其他安装副本前,需要用户重新决定。
Apple 的 [PPPC 配置](https://support.apple.com/guide/deployment/privacy-preferences-policy-control-payload-dep38df53c2a/web)支持 Bundle ID 或路径选择条件,同时另行要求代码签名条件。[TN3127](https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements)说明了指定要求如何跨版本识别代码,以及未签名和临时签名代码无法提供同等稳定性。DSH 采用这两种选择条件管理应用,不要求验证发布者身份。
## 考虑过的替代方案
**Bundle ID 加路径。** 可以区分安装副本,但用户希望共同授权的多个版本仍需分别确认。
**仅使用 Bundle ID。** 缺少标识会让本可操作的应用无法获得长期许可。规范化可执行路径提供稳定的后备标识,无须使用进程号或内容摘要。
**验证签名身份。** 可以区分发布者,但没有签名或验签失败时无法保存长期授权。发布者身份验证不属于本项应用管理规则。
## 后果
其他程序声明相同 Bundle ID,或替换路径授权对应的可执行文件时,会继承许可,包括通过其他已授权模型工具创建或修改的程序。最坏后果是在应用已有数据和能力范围内,读取或控制用户未单独批准的程序。模型不能在 CUA 请求中提交授权身份,该身份由原生 helper 从实际选中的应用读取。不同 Bundle ID 仍需分别授权,即使用户将它们视为同一产品的不同版本。
撤销作用于同一标识的所有安装副本。测试覆盖未签名副本、不同标识、缺少标识时的持久路径许可、进程重启、符号链接、启动前后身份一致、多个句柄共同撤销,以及不扩大旧授权。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-20-unambiguous-tool-arguments.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/bug-fix/2026-09-20-unambiguous-tool-arguments.md
/agent-note-unambiguous-tool-argument-compatibility:
en: dcb2b9835f9ee758
zh: 68be9713f74571c4
/agent-note-unambiguous-tool-argument-compatibility/problem:
en: c389c332bdadbbe4
zh: 217520278ccf0932
/agent-note-unambiguous-tool-argument-compatibility/decision:
en: 71c04efcfd972b02
zh: d1f842920b1b0e3f
/agent-note-unambiguous-tool-argument-compatibility/alternatives-considered:
en: b2a96695819a978f
zh: c6f84b244be0dddf
/agent-note-unambiguous-tool-argument-compatibility/consequences:
en: bd24ad737c617c90
zh: dbb7fa67c41af828
@@ -0,0 +1,25 @@
# Agent Note: Unambiguous tool argument compatibility
Status: implemented
English | [中文](2026-09-20-unambiguous-tool-arguments.zh.md)
## Problem
Models can populate optional fields that do not affect the requested operation. Rejecting these calls adds retries without protecting any state.
## Decision
`grep` treats an exact empty `include` as omitted. Whitespace-only, negated, and comma-separated filters remain invalid. `update_goal` uses replacement fields only for `edit` and a blocker reason only for `blocked`; other actions ignore those known fields. Declared types, consumed values, authority, and exact revisions remain validated. Ignored fields never reach another goal operation or change unrelated state, and call summaries select only values used by the action. `write` and `edit` allow absent or blank justification when no sandbox mode is requested or the effective mode is repeated; actual widening retains its reason and approval requirements, and filesystem observation and denial checks remain active.
This partially supersedes the unused-field rejection in the [goal-tool decision](../feature/2026-07-19-model-facing-goal-tools.md). Its authority, lifecycle, scheduling, and persistence decisions remain active. The rules apply to every provider and stay inside the owning tool rather than weakening its underlying service.
## Alternatives considered
- **Complete strict-schema adaptation first** — deferred because it is a separate provider-wide change; these cases have deterministic meanings and direct regression evidence.
- **Normalize all empty, null, or zero values** — rejected because such values can be meaningful input, including empty file contents and numeric limits.
- **Reject every populated inactive field** — rejected because the explicit action determines the goal operation, and unused fields cannot alter it.
## Consequences
The tools accept these calls without extra mutations. Unit tests cover ignored fields for every goal action, consumed-field and authority failures, stale revisions, filter validation, and real search results. A headless recorded-session scenario verifies both file extensions are searched and completion preserves the original objective and round cap. These rules give up detecting conflicting intent expressed only in inactive goal fields; they do not weaken authorization or establish a generic coercion policy.
@@ -0,0 +1,25 @@
# Agent Note: 无歧义的工具参数兼容
Status: implemented
[English](2026-09-20-unambiguous-tool-arguments.md) | 中文
## 问题
模型可能填写不影响所请求操作的可选字段。拒绝这些调用只会增加重试,并不保护任何状态。
## 决策
`grep` 将值恰好为空字符串的 `include` 视为省略。仅含空白、否定形式和逗号分隔的过滤器仍然无效。`update_goal` 仅在 `edit` 时使用替换字段,仅在 `blocked` 时使用阻塞原因;其他 action 忽略这些已知字段。声明的类型、实际使用的值、权限和精确修订号仍会校验。被忽略的字段不会传入另一种目标操作,也不会改变无关状态;调用摘要仅选择当前 action 使用的值。未请求沙箱模式或重复当前生效模式时,`write` 和 `edit` 允许省略理由或提供空白理由;真正升权仍需理由和审批,文件系统观察与拒绝检查继续生效。
本决策部分取代[目标工具决策](../feature/2026-07-19-model-facing-goal-tools.zh.md)中对无关字段的拒绝规则。其权限、生命周期、调度和持久化决策继续生效。这些规则适用于所有提供方,并保留在所属工具内,不削弱底层服务的校验。
## 考虑过的替代方案
- **先完成严格 schema 适配**——暂缓,因为这是单独的提供方级改造;这些情况具有确定含义和直接的回归证据。
- **统一规范化所有空值、null 或零值**——不予采纳,因为这些值可能是有意义的输入,包括空文件内容和数值限制。
- **拒绝所有已填写的非当前 action 字段**——不予采纳,因为显式 action 决定目标操作,而未使用的字段无法改变该操作。
## 后果
工具接受这些调用,且不会产生额外变更。单元测试覆盖每种目标 action 对无关字段的忽略、有效字段与权限失败、陈旧修订号、过滤器校验以及真实搜索结果。headless 会话回放验证两种扩展名的文件都参与搜索,并且完成操作保留原始目标和轮数上限。这些规则放弃检测仅由非当前 action 的目标字段表达的冲突意图;它们不削弱授权,也不建立通用类型转换策略。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-25-shared-preset-delegation-tools.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/bug-fix/2026-10-25-shared-preset-delegation-tools.md
/agent-note-shared-preset-delegation-tools:
en: 2f33b8a19d8291b8
zh: 4f49de632ac53d01
/agent-note-shared-preset-delegation-tools/problem:
en: 3150532ae6efd14c
zh: 3c3d662c5bcb323b
/agent-note-shared-preset-delegation-tools/decision:
en: ccaa0792d54fe79d
zh: b2993214c35c1db0
/agent-note-shared-preset-delegation-tools/alternatives-considered:
en: 2f53caf2af750178
zh: cd4d9b823a9348dc
/agent-note-shared-preset-delegation-tools/consequences:
en: 4aaaaec2038b3f1d
zh: b52bdd02118ffb6f
@@ -0,0 +1,31 @@
# Agent Note: Shared preset delegation tools
Status: implemented
English | [中文](2026-10-25-shared-preset-delegation-tools.zh.md)
## Problem
Agents sharing a preset need the same delegation definitions immediately after a preset switch. Installing identical tools separately for each Agent introduces an asynchronous visibility gap and places those definitions in the Agent's own registration layer, which is exempt from inherited-tool restrictions.
## Decision
Each tool-subagent instance registers its definition in its owning composition. Agents sharing a preset revision inherit that registration. Switching presets changes ancestry; it requires no per-Agent installation or tool-change reconciliation. Provider availability and the owning composition's disposal control registration lifetime.
Tool configuration validates its own fields, allowlist consistency, required services and provider capabilities. Candidate preset documents are not read during registration, for either Host-mounted or preset-mounted tools. The delegation executor validates the model's selection against the allowlist; child startup resolves the selected id against the current registry. A later declaration can therefore satisfy a previously unavailable target without reinstalling the tool. Independent selection and restoration retain the [spawn preset semantics](../feature/2026-09-20-spawn-agent-preset-selection.md).
The schema contains preset ids and default or inheritance guidance without candidate descriptions. This keeps shared definitions independent of other presets' declaration order and avoids retaining document text that can become stale. [Awaited Agent initialization](../architecture/2026-09-09-awaited-agent-creation.md) remains necessary for other asynchronous contributions.
## Alternatives considered
**Reinstall tools for each Agent.** Identical definitions gain extra installation, disposal and reconciliation work, and Agent-local ownership prevents inherited-tool restrictions from applying.
**Validate every target at tool registration.** A target declared later is rejected even though it can be available when used. An early successful lookup also cannot guarantee availability after registry changes.
**Retain candidate descriptions in shared schemas.** Reading descriptions during registration makes the result depend on declaration order. Keeping those descriptions current requires additional update coordination; the tool exposes ids instead.
## Consequences
Unknown or unloadable targets fail the selected delegation without leaving a child Agent or Session. An unused invalid reference remains undetected. Agent restrictions and restored historical filters apply to inherited delegation schemas, execution and guidance; sibling Agents retain their tools.
Focused preset-selection tests cover immediate switch visibility, shared ownership and disposal, restrictions, missing targets and retry after declaration. The Web Loader cases cover shipped composition compatibility, and the headless preset snapshot pins model-visible choices. The historical-filter tests exercise child composition directly; they do not establish cold-resume coverage.
@@ -0,0 +1,31 @@
# Agent Note: 预设共享委派工具
Status: implemented
[English](2026-10-25-shared-preset-delegation-tools.md) | 中文
## 问题
共享预设的 Agent(智能体)需要在预设切换完成后立即取得相同的委派定义。为每个 Agent 分别安装相同工具会产生异步可见性缺口,并将定义放在 Agent 自身的注册层,而该层不受继承工具限制约束。
## 决策
每个 tool-subagent 实例在所属组装中注册定义。共享同一预设代际的 Agent 继承该注册。切换预设只改变祖先关系,无须按 Agent 安装或协调工具变更。提供方可用性及所属组装的 dispose(资源释放)决定注册的生命周期。
工具配置校验自身字段、白名单一致性、必需服务及提供方能力。Host 挂载和预设内挂载的工具都不在注册时读取候选预设文档。委派执行器按白名单校验模型选择,子级启动时再从当前注册表解析所选 id。因此,后续声明能够使原先不可用的目标变得可用,无须重新安装工具。独立选择与恢复保留 [spawn 预设语义](../feature/2026-09-20-spawn-agent-preset-selection.zh.md)。
schema 包含预设 id 及默认或继承提示,不包含候选描述。这使共享定义不依赖其他预设的声明顺序,也避免保留可能陈旧的文档文本。其他异步贡献仍需要[等待 Agent 初始化](../architecture/2026-09-09-awaited-agent-creation.zh.md)。
## 考虑过的替代方案
**为每个 Agent 重新安装工具。** 相同定义需要额外的安装、释放与协调工作,Agent 本地所有权还会阻止继承工具限制生效。
**在工具注册时校验所有目标。** 后声明的目标即使在使用时可用,也会被拒绝。提前查找成功同样无法保证注册表变更后的可用性。
**在共享 schema 中保留候选描述。** 注册时读取描述会使结果依赖声明顺序。保持描述同步需要额外的更新协调;工具选择公开 id。
## 后果
未知或无法加载的目标会使选中它的委派失败,不残留子 Agent 或 Session。未使用的无效引用仍不会被发现。Agent 限制及恢复的历史过滤器约束继承的委派 schema、执行与指导文本;同级 Agent 保留各自的工具。
针对性预设选择测试覆盖切换完成时的即时可见性、共享所有权与释放、限制、缺失目标及补声明后重试。Web Loader 用例覆盖 shipped 组装兼容性,headless 预设快照固定模型可见选项。历史过滤器测试直接执行子级组装,不证明冷恢复覆盖。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-26-malformed-charset-declaration.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/bug-fix/2026-10-26-malformed-charset-declaration.md
/agent-note-malformed-charset-recovery-prefers-utf-8:
en: d8a759b84c36b308
zh: 3b6ff2eb5519afb1
/agent-note-malformed-charset-recovery-prefers-utf-8/problem:
en: dce96445b3801a7f
zh: e026d26fa680556a
/agent-note-malformed-charset-recovery-prefers-utf-8/decision:
en: ba74ad57757231ca
zh: 46999cd0100814ae
/agent-note-malformed-charset-recovery-prefers-utf-8/alternatives-considered:
en: 9fafffb6a7a78a29
zh: ee1dcc27d0315e68
/agent-note-malformed-charset-recovery-prefers-utf-8/consequences:
en: c8aad3cc3f02e81e
zh: 29d9313afd1c7b5b
@@ -0,0 +1,33 @@
# Agent Note: Malformed charset recovery prefers UTF-8
Status: implemented
English | [中文](2026-10-26-malformed-charset-declaration.zh.md)
## Problem
A response can declare `charset=utf-8,gbk` over readable UTF-8 HTML. Passing that entire parameter to `TextDecoder` rejects the page before reading it. Compatibility needs an explicit encoding preference because successful decoding alone cannot identify the server's intended encoding.
## Decision
`decoderForCharset` preserves valid single-label declarations and the absent-header UTF-8 default. If a declaration is unsupported as a whole, it checks its comma- and whitespace-separated labels for UTF-8, including standard aliases. A match selects a fatal UTF-8 decoder; otherwise the declaration fails before the response body is read and the provider cancels the stream.
The provider decodes once. Recovery rejects invalid UTF-8 with `WEB_UNSUPPORTED_CONTENT_TYPE` and preserves the decoding error as its cause. Only an actual byte-cap truncation enables streaming decode, which omits an incomplete suffix and keeps `truncated: true`. Complete responses, including those exactly at the cap, validate the final sequence at EOF.
UTF-8 recovery is an explicit heuristic, not proof of the original encoding. The bytes `C2 A9` represent `©` in UTF-8 and `漏` in GBK; repeating them preserves that ambiguity at any length. The provider accepts that ambiguity for UTF-8 recovery but does not extend it to arbitrary legacy-encoding guesses.
## Alternatives considered
**Choose the first declared encoding that decodes.** A GBK body can also decode as `windows-1252` with different text. Declaration order does not resolve the conflict, and single-byte encodings can turn a decoding failure into silent corruption. Rejected.
**Use strict probes followed by ordinary decoding.** This decodes successful bodies twice. An unflushed streaming probe also accepts an incomplete EOF sequence that the ordinary decoder replaces. One fatal decode with explicit truncation state is sufficient.
**Read BOM and HTML meta declarations.** This would add HTML encoding identification and a dependency or parser, with a separate policy for missing metadata and non-HTML content. It remains deferred; the bounded UTF-8 preference resolves the reported response without claiming browser-equivalent detection.
## Consequences
- Malformed declarations containing UTF-8 can return readable text; a failed UTF-8 recovery never falls back to another encoding or inserts replacement characters.
- Malformed declarations without a UTF-8 label remain unsupported, including repeated legacy aliases. Valid single-label legacy decoding is unchanged.
- [`fetch-http.spec.ts`](../../../../packages/web/web-fetch-http/tests/fetch-http.spec.ts) covers recovery, conflicting labels, invalid bytes, EOF, truncation, and response-stream cancellation.
- The recorded [`web-fetch`](../../../../snapshots/session/web-fetch/snapshot.yml) scenario serves literal UTF-8 characters with `charset=gbk,utf-8` through the real HTTP provider and verifies the same model-visible text.
- HTML `<meta charset>` remains ignored. The package README documents the remaining ambiguity.
@@ -0,0 +1,33 @@
# Agent Note: 畸形 charset 声明的恢复优先使用 UTF-8
Status: implemented
[English](2026-10-26-malformed-charset-declaration.md) | 中文
## Problem
响应可能在可读的 UTF-8 HTML 上声明 `charset=utf-8,gbk`。把整个参数交给 `TextDecoder` 会在读取前拒绝页面。兼容处理需要明确的编码优先规则,因为解码成功本身无法识别服务器原本使用的编码。
## Decision
`decoderForCharset` 保留合法的单标签声明,以及未声明时默认使用 UTF-8 的行为。如果整个声明不受支持,它检查按逗号和空白分隔的标签是否包含 UTF-8(含标准别名)。匹配时选择 fatal UTF-8 解码器;否则在读取正文前拒绝声明,由提供方取消响应流。
提供方只解码一次。恢复路径以 `WEB_UNSUPPORTED_CONTENT_TYPE` 拒绝非法 UTF-8,并保留解码错误作为 cause。只有真正触发字节上限截断时才启用流式解码,省略不完整的末尾序列并保持 `truncated: true`。完整响应(包括恰好达到上限的响应)在 EOF 校验末尾序列。
UTF-8 恢复是明确的启发式规则,不是原始编码的证明。字节 `C2 A9` 在 UTF-8 下表示 `©`,在 GBK 下表示 `漏`;重复这些字节后,任意长度都保留该歧义。提供方为 UTF-8 恢复接受这种歧义,但不将其扩展为任意传统编码的猜测。
## Alternatives considered
**选择声明中第一个能解码的编码。** GBK 正文也可能按 `windows-1252` 解出不同文本。声明顺序不能解决冲突,单字节编码还可能把解码失败变成静默的内容损坏。拒绝。
**先严格探测,再常规解码。** 这会对成功的正文解码两次。未结束的流式探测还会接受 EOF 处不完整的序列,随后被常规解码器替换。一次 fatal 解码配合显式截断状态已经足够。
**读取 BOM 和 HTML meta 声明。** 这会增加 HTML 编码识别能力及依赖或解析器,还需单独规定缺少元数据和非 HTML 内容的策略。继续延期;有范围的 UTF-8 优先规则可以处理所报告的响应,无需宣称与浏览器等价的编码检测。
## Consequences
- 含有 UTF-8 的畸形声明可以返回可读文本;UTF-8 恢复失败时不会回退到其他编码,也不会插入替换字符。
- 不含 UTF-8 标签的畸形声明仍不受支持,包括重复的传统编码别名。合法的单标签传统编码解码保持不变。
- [`fetch-http.spec.ts`](../../../../packages/web/web-fetch-http/tests/fetch-http.spec.ts) 覆盖恢复、冲突标签、非法字节、EOF、截断和响应流取消。
- 已录制的 [`web-fetch`](../../../../snapshots/session/web-fetch/snapshot.yml) 场景通过真实 HTTP 提供方,以 `charset=gbk,utf-8` 提供包含原样 UTF-8 字符的页面,并验证相同的模型可见文本。
- HTML `<meta charset>` 仍然被忽略。包 README 记录了剩余歧义。
@@ -12,8 +12,8 @@
en: 0ab1e1c40cb2dac9
zh: b4180b6681c130b2
/agent-note-model-facing-same-session-goal-tools/decision/tools-and-model-contract:
en: 288a819f644611c9
zh: d6bd7986ba091519
en: 8589421c225beac0
zh: d2a49dec31ba260c
/agent-note-model-facing-same-session-goal-tools/decision/execution-authority:
en: 63a9e7e85303b9f1
zh: d3f1d0c225103cfe
@@ -24,8 +24,8 @@
en: 92650dd6648ca455
zh: 5b57276a6a983fea
/agent-note-model-facing-same-session-goal-tools/alternatives-considered:
en: e84b9fc4823bcc1e
zh: a125628e4746bf50
en: 84f7829c020f282b
zh: 37c4366efc9dd8dc
/agent-note-model-facing-same-session-goal-tools/consequences:
en: e254571a176b37e5
zh: 01f76aff35e3618d
@@ -16,7 +16,7 @@ The tool API also needs to preserve the separation between durable state and liv
### Tools and model contract
`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. A durable paused goal rejects `resume` with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. The executor treats exact empty-string optional fields and a zero `max_goal_rounds` as strict-schema fillers: they count as omitted, an edit still requires at least one meaningful replacement, and all non-filler values retain the action restrictions.
`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are used only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. A durable paused goal rejects `resume` with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. For `edit`, exact empty-string objectives and a zero `max_goal_rounds` count as omitted, and at least one meaningful replacement is required. Each action ignores known optional fields it does not use ([rationale](../bug-fix/2026-09-20-unambiguous-tool-arguments.md)).
The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. The prompt does not announce the durable paused boundary; execution rejects that attempt with `GOAL_TOOL_RESUME_PAUSED`, and the user-facing resume path owns the transition. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition.
@@ -48,7 +48,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
- **Authorize from persisted root or fork metadata** — rejected because a fork that becomes an independently resumed top-level session should accept new human authority, while a currently owned child should not.
- **Let autonomous rounds edit or resume the goal** — rejected because continuation authority is narrower than authority to redefine or restart the human objective.
- **Treat the blocked threshold as an evaluator** — rejected because event counts cannot prove that an obstacle is semantically unchanged or truly terminal.
- **Reject every present action-specific field** — rejected because strict-schema providers can serialize zero-value placeholders for every optional field; only meaningful values can express a conflicting action.
- **Reject every present action-specific field** — rejected because strict-schema providers can serialize zero-value placeholders for every optional field; only meaningful values can express a conflicting action. The [unused-field decision](../bug-fix/2026-09-20-unambiguous-tool-arguments.md) defines which populated fields are ignored.
## Consequences
@@ -16,7 +16,7 @@ Status: implemented
### 工具与模型约定
`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大 Goal Round 数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。持久 paused goal 会以 `GOAL_TOOL_RESUME_PAUSED` 拒绝 `resume`;面向用户的命令或 Web 控件拥有该转换。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。
`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大 Goal Round 数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅在 `edit` 时使用,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。持久 paused goal 会以 `GOAL_TOOL_RESUME_PAUSED` 拒绝 `resume`;面向用户的命令或 Web 控件拥有该转换。对于 `edit`,值恰好为空字符串的目标描述和值为 0 的 `max_goal_rounds` 视为省略,且至少需要一个有实际意义的替换字段。每个 action 都忽略它不使用的已知可选字段([理由](../bug-fix/2026-09-20-unambiguous-tool-arguments.zh.md))。
提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或 fork 后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。提示词不会静态声明持久 paused 的边界;执行时以 `GOAL_TOOL_RESUME_PAUSED` 拒绝该尝试,面向用户的恢复路径拥有该转换。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。
@@ -48,7 +48,7 @@ Status: implemented
- **根据持久的根或 fork 元数据授权**——不予采纳,因为作为顶层会话独立恢复的 fork 应接受新的人类权限,而当前仍受所有权约束的 subagent 则不应接受。
- **允许自主 Round 编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。
- **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。
- **拒绝所有已提供的特定操作字段**——不予采纳,因为采用严格 schema 的提供方可能为每个可选字段序列化零值占位符;只有有实际意义的字段值才能表示与指定操作相冲突的另一项操作。
- **拒绝所有已提供的特定操作字段**——不予采纳,因为采用严格 schema 的提供方可能为每个可选字段序列化零值占位符;只有有实际意义的字段值才能表示与指定操作相冲突的另一项操作。[无关字段决策](../bug-fix/2026-09-20-unambiguous-tool-arguments.zh.md)规定了哪些已填写字段会被忽略。
## 后果
@@ -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
@@ -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.
@@ -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 事件可用,但仍未调优。
@@ -12,17 +12,17 @@
en: 34e5bde1535a2bb1
zh: 559deb25b2ec50ba
/agent-note-domain-kv-storage-capability-seam-and-the-workspace-entity/proposal/dsh-storage-the-storage-hub:
en: 191afc01a150e4a0
zh: da3fbc33875a4f60
en: 5eb0b18c2adf5111
zh: 856ee7b1464b8bfc
/agent-note-domain-kv-storage-capability-seam-and-the-workspace-entity/proposal/dsh-storage-json:
en: a18ec1e534a5488b
zh: 2f5beb445e30554b
/agent-note-domain-kv-storage-capability-seam-and-the-workspace-entity/proposal/dsh-storage-sqlite:
en: 6a26dd7ef357db74
zh: 951b31136e2415f9
en: 1a4bebbbf52db7a2
zh: 425c32b0dc17b49e
/agent-note-domain-kv-storage-capability-seam-and-the-workspace-entity/proposal/dsh-domain-the-domain-data-form:
en: 2ee24f36cd93f7dd
zh: 45c83bd745de41f9
en: b99e07359dc78dc8
zh: 4af881d0b6d5b628
/agent-note-domain-kv-storage-capability-seam-and-the-workspace-entity/proposal/future-work-session-side-deletion-design-settled-not-implemented-this-phase:
en: a3824326587b6539
zh: 9736e3060bd57651
@@ -44,9 +44,9 @@ A backend is one **medium owner** (a file-tree root / one db file) exposing prim
The backend contract (asserted clause by clause by the shared conformance suite, one suite for both backends):
1. `open` creates when the medium holds nothing (lazy materialization allowed: may defer to the first write, but `loadAll` must immediately serve empty tables); loads when the medium exists.
2. A stored version ≠ descriptor.version → `StorageError('version-mismatch')`; no migration, no rebuild.
2. Unit-wide checks accept `descriptor.version` and the owner's `compatibleVersions`; other stamps reject with `StorageError('version-mismatch')`. Ordinary compatible reads preserve saved stamps and values. [Domain versioning](../../../../docs/subsystems/storage.md#domain-versioning) defines write stamping and JSON per-record exceptions.
3. Durability: after a write primitive resolves, a process crash followed by a re-open must observe the write in `loadAll`.
4. The backend does not promise write ordering within a unit — **the caller serializes**; the backend only guarantees each single call is atomic (JSON whole-file replace / SQLite single statement).
4. The backend does not promise write ordering within a unit — **the caller serializes**; whole-unit mutations atomically publish their data and current stamp (JSON whole-file replacement / SQLite transaction for batch deletion or advancing the stamp); per-record batches can be partially applied if interrupted before resolution.
5. `deleteRecord` is idempotent; `putRecord` overwrites.
6. Any string key / any JSON value is safe (keys never reach file paths, a structural property).
7. `close` is idempotent; any operation after close → `StorageError('closed')`.
@@ -88,8 +88,8 @@ CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = record JSON document
```
- Unit versions live in `units` rows; a descriptor mismatch → `version-mismatch`. Row granularity is document-per-row, preserving precise per-key durable updates (the path left open for high-frequency point-update tables like the session sidecar); when query needs appear, JSON1 reads the value column directly.
- Write primitives are single statements and thus atomic; no cross-statement transactions needed (the domain layer has no cross-table transactions, see the out-of-scope list).
- Unit versions live in `units` rows; an unaccepted version rejects with `version-mismatch`. Row granularity is document-per-row, preserving precise per-key durable updates (the path left open for high-frequency point-update tables like the session sidecar); when query needs appear, JSON1 reads the value column directly.
- Current-version single-record writes use single statements. Batch deletes and the first mutation of an accepted older unit use one transaction; the first mutation commits its data and current stamp together. This does not add cross-table domain transactions.
### `dsh-domain`: the domain data form
@@ -117,6 +117,7 @@ export interface DomainTableSpec<K extends string, V> { readonly valueSchema: Zo
export interface DomainSpec {
readonly name: string // ^[a-z][a-z0-9_]*$
readonly version: number
readonly compatibleVersions?: readonly number[]
readonly global?: DomainGlobalSpec<unknown>
readonly tables: Record<string, DomainTableSpec<string, unknown>>
}
@@ -157,8 +158,8 @@ Rules:
- **Single-level mapping**: key → record, no nested tables; hierarchical needs use composite keys or fields inside the value. The two backends stay isomorphic as a result (one JSON object level ↔ one SQLite row).
- **Records are plain data**: immutable, directly JSON-serializable POJOs; values returned by `get`/`entries` must not be mutated in place (TypeScript readonly projection, no runtime freezing). Behavior-carrying domain objects belong to consumer packages.
- **Serialized writes**: one promise chain per domain; `put`/`delete`/`update`/`global.set` all queue on it; `update`'s fn runs on the chain, so concurrency cannot interleave. No active-record (pulling out a mutable object that auto-persists — uncontrollable persist timing, in conflict with the whole-unit atomic-rewrite model).
- **Version fails loud**: a stored version differing from the spec throws outright; no migration, no rebuild (the data is not regenerable; pre-release rejects old formats).
- **Serialized writes**: one promise chain per domain; `put`/`delete`/`deleteMany`/`update`/`global.set` all queue on it; `update`'s fn runs on the chain, so concurrency cannot interleave. No active-record (pulling out a mutable object that auto-persists — uncontrollable persist timing, in conflict with the whole-unit atomic-rewrite model).
- **Version compatibility**: the domain owner declares readable older versions through `compatibleVersions` and decodes historical values with its current schemas. Storage does not convert record values; [domain versioning](../../../../docs/subsystems/storage.md#domain-versioning) defines stamp publication and mixed-generation requirements.
- **Change events**: after each write's durability resolves, emit `domain/changed` (`@mode emit`), one per record, no old value (matching the repository's "new snapshot + operation discriminant" convention, template `goal/changed`); the payload `DomainChanged` is a put/deleted discriminated union — domain + table + key (both `''` for global changes) + operation, with the put branch carrying the new snapshot value and the deleted branch carrying none (`packages/storage/storage-domain/src/events.ts`). This is next phase's RPC push-frame event source. The error vocabulary is `DomainError`, codes: `already-open` / `facet-unsupported` / `invalid-record` (with `{ table, key }`) / `missing-key` / `closed`.
### Future work: session-side deletion (design settled, not implemented this phase)
@@ -44,9 +44,9 @@ host 侧唯一的持久化面是 session 事件日志(`packages/session/sessio
后端约定(共享约定测试逐条断言,两后端同套件):
1. `open` 对不存在的介质创建(懒物化允许:可延迟到首写,但 `loadAll` 立即可用返回空表);对已存在介质载入。
2. 介质上版本 ≠ descriptor.version → `StorageError('version-mismatch')`,不迁移不重建。
2. 单元级检查接受 `descriptor.version` 和拥有方声明的 `compatibleVersions`;其他版本戳以 `StorageError('version-mismatch')` 拒绝。普通兼容读取保留已存版本戳和值。[领域版本](../../../../docs/subsystems/storage.zh.md#domain-versioning)定义写入盖戳和 JSON 逐记录例外。
3. 持久性:写原语 resolve 后进程崩溃再 open,`loadAll` 必须反映该写入。
4. 后端不承诺 unit 内写并发序——**调用方负责串行**;后端只保证单次调用原子(JSON 整文件替换 / SQLite 单语句)。
4. 后端不承诺 unit 内写并发序——**调用方负责串行**;整单元修改原子发布其数据和当前版本戳(JSON 整文件替换 / SQLite 在批量删除或推进版本戳时使用事务);逐记录批次在完成前中断时可能只应用一部分。
5. `deleteRecord` 幂等;`putRecord` 覆写。
6. 任意字符串 key / 任意 JSON 值安全(key 不进文件路径,结构性质)。
7. `close` 幂等;close 后任何操作 → `StorageError('closed')`。
@@ -88,8 +88,8 @@ CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = record JSON document
```
- unit 版本存 `units` 行,descriptor 不符 → `version-mismatch`。行粒度 document-per-row,保住按 key 精确落盘更新(为 session sidecar 这类高频点更新大表留路);查询需求出现时 JSON1 直查 value 列。
- 写原语单语句即原子,无跨语句事务需求(domain 层无跨表事务,见不做清单)。
- unit 版本存 `units` 行,未接受的版本以 `version-mismatch` 拒绝。行粒度 document-per-row,保住按 key 精确落盘更新(为 session sidecar 这类高频点更新大表留路);查询需求出现时 JSON1 直查 value 列。
- 当前版本的单记录写入使用单条语句。批量删除和对已接受旧单元的首次修改使用一个事务;首次修改一同提交数据和当前版本戳。这不增加跨表领域事务。
### `dsh-domain`:领域数据形式
@@ -117,6 +117,7 @@ export interface DomainTableSpec<K extends string, V> { readonly valueSchema: Zo
export interface DomainSpec {
readonly name: string // ^[a-z][a-z0-9_]*$
readonly version: number
readonly compatibleVersions?: readonly number[]
readonly global?: DomainGlobalSpec<unknown>
readonly tables: Record<string, DomainTableSpec<string, unknown>>
}
@@ -157,8 +158,8 @@ export interface KvTable<K extends string, V> {
- **一级 mapping**:key → 记录,不做嵌套表;层级需求用复合 key 或值内字段。两后端因此同构(JSON object 一层 ↔ SQLite 一行)。
- **记录是纯数据**:可直接 JSON 序列化的不可变 POJO;`get`/`entries` 返回值不得原地改(TypeScript readonly 投影,不做运行时冻结)。带行为的领域对象属于消费方包。
- **写串行**:域内一条 promise 链,`put`/`delete`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record(取出可变对象自动落盘——落盘时机不可控,与整域原子覆写冲突)。
- **版本 fail loud**:盘上版本与 spec 不符直接报错,不迁移不重建(数据不可再生,pre-release 拒绝旧格式)。
- **写串行**:域内一条 promise 链,`put`/`delete`/`deleteMany`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record(取出可变对象自动落盘——落盘时机不可控,与整域原子覆写冲突)。
- **版本兼容**:领域拥有方通过 `compatibleVersions` 声明可读的旧版本,并用当前 schema 解码历史值。Storage 不转换记录值;[领域版本](../../../../docs/subsystems/storage.zh.md#domain-versioning)定义版本戳发布和混合代际要求。
- **变更事件**:每次写落盘 resolve 后 emit `domain/changed`(`@mode emit`),逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`);payload `DomainChanged` 是 put/deleted 判别联合——域名 + 表名 + key(global 变更两者为 `''`)+ operation,put 支带新快照 value、deleted 支无 value(`packages/storage/storage-domain/src/events.ts`)。此为下期 RPC 推帧的事件源。错误词汇 `DomainError`,码表:`already-open` / `facet-unsupported` / `invalid-record`(带 `{ table, key }`)/ `missing-key` / `closed`。
### Future work:session 侧删除(设计定案,本期不实施)
+3
View File
@@ -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
+2
View File
@@ -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`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job.
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.
@@ -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 }}
@@ -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 }}
@@ -0,0 +1,107 @@
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
# Merging is the precondition of the build below, so a failure here warns and skips it in `report` mode and fails
# in `block` mode.
- id: merge
name: Merge the checked base-branch commit
env:
CHECK_MODE: ${{ vars.MERGE_POLICY_PRE_MERGE }}
BASE_SHA: ${{ needs.master.outputs.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
SINCE: ${{ needs.master.outputs.since }}
run: |
stop() {
if [ "$CHECK_MODE" = block ]; then
echo "::error title=Pre-Merge Check::$1"
exit 1
fi
echo "::warning title=Pre-Merge Check::$1"
exit 0
}
if [[ ! "$BASE_SHA" =~ ^[0-9a-f]{40}$ || ! "$SINCE" =~ ^[0-9]+$ ]]; then
stop "the master diff check reported no base commit, so nothing was merged or compiled"
fi
git fetch --no-tags --no-write-fetch-head --shallow-since="$SINCE" origin "$HEAD_SHA" "$BASE_SHA" ||
stop "the base commit $BASE_SHA could not be fetched, so nothing was merged or compiled"
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" ||
stop "the pull request cannot merge $BASE_SHA; merge master into it and push, then this check runs again"
echo "merged=true" >> "$GITHUB_OUTPUT"
- uses: pnpm/action-setup@v5.0.0
if: steps.merge.outputs.merged == 'true'
- uses: actions/setup-node@v7.0.0
if: steps.merge.outputs.merged == 'true'
with:
node-version: '24'
package-manager-cache: false
- name: Run the pre-merge command
if: steps.merge.outputs.merged == 'true'
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
echo "::error title=Pre-Merge Check::the merged tree does not install or compile"
exit 1
fi
echo "::warning title=Pre-Merge Check::the merged tree does not install or compile; this check does not block"
fi
+1
View File
@@ -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 |
+6 -1
View File
@@ -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<string, string> }
}>((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<string, string> } })
}
})
child.once('error', reject)
child.once('exit', (code) => { reject(new Error(`Host exited before booting: ${String(code)} ${stderr}`)) })
})
@@ -0,0 +1,27 @@
- insert:
- id: preset-delegate-a
name: '@deepseek-ai/dsh-agent-preset'
config:
id: delegate-a
plugins:
- name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
allowedAgentPresets: [standard, ptc, minimal]
- name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
- id: preset-delegate-b
name: '@deepseek-ai/dsh-agent-preset'
config:
id: delegate-b
plugins:
- name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
allowedAgentPresets: [standard, ptc, minimal]
- name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
+78
View File
@@ -355,6 +355,49 @@ describe('the shipped Web composition', () => {
}
})
it.each(['standard', 'ptc'] as const)('retains delegation tools after repeated preset round trips ending in %s', async (target) => {
const adapter = new MinimalCompactionAdapter()
const unregister = ctx.llm.registerAdapter(['preset-round-trip'], adapter)
onTestFinished(unregister)
const direct = await ctx.agents.create({
sessionId: SessionId(`preset-direct-${target}-${randomUUID()}`),
agentOptions: { provider: 'preset-round-trip', model: 'preset-round-trip' },
setup: agentCtx => ctx.agentPresets.mount(agentCtx, target).then(() => undefined),
})
onTestFinished(() => direct.dispose())
const handle = await ctx.agents.create({
sessionId: SessionId(`preset-round-trip-${target}-${randomUUID()}`),
agentOptions: { provider: 'preset-round-trip', model: 'preset-round-trip' },
setup: agentCtx => ctx.agentPresets.mount(agentCtx, target).then(() => undefined),
})
onTestFinished(() => handle.dispose())
const { agent } = handle
const other = target === 'standard' ? 'ptc' : 'standard'
for (const preset of [other, target, other, target]) {
await ctx.agentPresets.select(agent, preset)
expect(toolNames(ctx, agent)).toEqual(expect.arrayContaining(['subagent', 'subagent_fork']))
}
expect(ctx.tools.schemas(agent)).toEqual(ctx.tools.schemas(direct.agent))
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
if (target === 'ptc') {
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('subagent:')
expect(sdk).toContain('subagent_fork:')
}
direct.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Reply with Ready.' }], source: { kind: 'user' } }))
await direct.agent.whenIdle()
const directRequest = adapter.requests.find(request => request.purpose === undefined)
expect(directRequest).toBeDefined()
adapter.requests.length = 0
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Reply with Ready.' }], source: { kind: 'user' } }))
await agent.whenIdle()
const request = adapter.requests.find(request => request.purpose === undefined)
expect(request).toBeDefined()
expect(request?.tools).toEqual(directRequest?.tools)
const header = agent.session.snapshotEvents().find(event => event.type === 'request/header')
expect(header?.data.header.tools).toEqual(request?.tools)
})
it('composes the exact RL prompt and persistent shell from `minimal`', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-minimal'),
@@ -683,6 +726,41 @@ describe('the shipped Web composition', () => {
})
})
describe('preset-selected delegation through the Web Loader', () => {
it('exposes configured delegation tools when each preset switch resolves', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-delegation-switch-'))
try {
const configured = await bootWeb(root, loadOverlayPatches('delegation-switch',
join(REPO_ROOT, 'apps/cli/tests/profiles/subagent-preset-switch.overlay.yml')))
try {
const adapter = new MinimalCompactionAdapter()
configured.llm.registerAdapter(['preset-switch'], adapter)
const { agent } = await configured.agents.create({
sessionId: SessionId(`configured-switch-${randomUUID()}`),
agentOptions: { provider: 'preset-switch', model: 'preset-switch' },
setup: owner => configured.agentPresets.mount(owner, 'delegate-a').then(() => undefined),
})
const views: Array<{ preset: string; names: string[] }> = []
for (const preset of ['delegate-b', 'delegate-a', 'delegate-b', 'delegate-a', 'ptc', 'standard', 'delegate-a']) {
await configured.agentPresets.select(agent, preset)
views.push({ preset, names: toolNames(configured, agent) })
}
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Reply with Ready.' }], source: { kind: 'user' } }))
await agent.whenIdle()
for (const { preset, names } of views) {
expect(names, `tools after selecting ${preset}`).toEqual(expect.arrayContaining(['subagent', 'subagent_fork']))
}
const request = adapter.requests.find(request => request.purpose === undefined)
expect(request?.tools?.map(tool => tool.name)).toEqual(expect.arrayContaining(['subagent', 'subagent_fork']))
expect(request?.tools?.find(tool => tool.name === 'subagent'))
.toHaveProperty('parameters.properties.agent_preset.enum', ['standard', 'ptc', 'minimal'])
const header = agent.session.snapshotEvents().find(event => event.type === 'request/header')
expect(header?.data.header.tools).toEqual(request?.tools)
} finally { await configured.fiber.dispose() }
} finally { await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }
})
})
describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surface) => {
const products = ['codex', 'claude-code'] as const
type Product = typeof products[number]
+2 -1
View File
@@ -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:~",
+59
View File
@@ -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<Error>()
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
}
+8 -5
View File
@@ -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<void> {
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<void> {
})
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<void> {
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
@@ -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<void>, maxPending: number, runId: PluginOperationRunId,
): void {
if (!Number.isSafeInteger(maxPending) || maxPending < 1) throw new RangeError('Invalid diagnostic delivery budget')
const pending = new Set<Promise<void>>()
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)
})
}
@@ -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)
})
@@ -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<undefined>()
const release = Promise.withResolvers<undefined>()
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()
})
+3
View File
@@ -31,6 +31,9 @@
{
"path": "../../packages/credentials/deepseek-account"
},
{
"path": "../../packages/boot/plugin-manager"
},
{
"path": "../../packages/fs/fs"
},
+6 -6
View File
@@ -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
+5 -8
View File
@@ -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-<UTC time>-<source>.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-<timestamp>` (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
+5 -8
View File
@@ -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-<UTC 时间>-<source>.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-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
Desktop 的致命故障通过原生恢复对话框提供退出、重启、禁用第三方插件并备份 profile patch 后重启,以及获取诊断报告。触发范围、报告位置与内容、近期操作记录和退出限制见[故障恢复与诊断](docs/diagnostics/README.zh.md)。
### Host 环境
@@ -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
+76
View File
@@ -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)
<a id="recovery"></a>
## 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-<timestamp>` (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.
<a id="report-contents"></a>
## 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-<UTC time>-<source>.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.
<a id="operation-records"></a>
## 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.
<a id="collection-and-shutdown"></a>
## 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.
@@ -0,0 +1,76 @@
---
description: "排查 Desktop 故障或收集报告时阅读,了解恢复操作、报告内容、操作历史和退出限制。"
---
# Desktop 故障恢复与诊断
[English](README.md) | 中文
## 概述
本参考文档说明 Desktop 的原生故障恢复、诊断报告、近期插件操作记录,以及采集和退出时的限制。
## 目录
- [遇到故障怎么办](#recovery)
- [报告包含什么](#report-contents)
- [近期操作记录](#operation-records)
- [采集与退出行为](#collection-and-shutdown)
<a id="recovery"></a>
## 遇到故障怎么办
主窗口创建、主文档加载、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-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
<a id="report-contents"></a>
## 报告包含什么
原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断,并附上崩溃报告路径或仍在保存、无法保存的状态。获取仍在保存或无法保存的报告时,保留恢复操作失败的标题和详情。配置路径和启动条目排在组合清单之前,避免输出截断时被插件记录挤掉。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
首个致命弹窗打开前,Electron 会向平台日志目录(`app.getPath('logs')`:macOS 为 `~/Library/Logs/DeepSeek Harness`,Windows 与 Linux 为应用 `userData` 目录下的 `logs`)写入一份崩溃报告,最多等待写入一秒;写入缓慢或失败时明确显示仍在保存或无法保存。“获取诊断报告”检查同一次写入,并在系统文件管理器中定位已保存的文件,不停止 Host 或重启;超过首次等待期限后写入完成的文件仍可获取。用户可将文件作为工单附件。附件只包含最初的故障;后续恢复操作错误保留在对话框中,需要另行截图。获取已保存报告会显示分享或打开失败的标题,同时保留恢复错误详情。写入失败时隐藏获取报告按钮,并提示提供窗口截图。
文件 `crash-<UTC 时间>-<source>.log` 记录来源(`host` 为 Host 退出、`web-boot` 为渲染进程启动失败、`renderer` 为渲染进程或文档失败、`main` 为壳自身错误)、后端是否已就绪、应用与运行时版本、包含可枚举属性与 cause 链的错误(截至 256 KiB)、Host 在退出前通过 IPC 报告启动失败时的错误及显式选择的启动元数据(最多 64 Ki 个字符,包含截断提示;启动错误的原始错误文本和启动条目详情分别限制为 8 Ki 个字符,并明确标注截断,为结构化元数据保留空间),以及主窗口最近的 error 级 console 输出(最多 64 KiB)。
因此 Host 退出报告包含保留的 stderr 尾部,其中可能含有插件输出。关闭过程中的致命失败只写报告、不弹窗。平台支持时文件仅所有者可读;启动时保留最新十份报告并删除更早的,不触碰目录中的其他文件。
<a id="operation-records"></a>
## 近期操作记录
操作记录目录与应用单实例锁均按 `userData` 隔离;共享平台日志目录不用于保存操作记录。读取器只读取 `userData/plugin-operations` 下的两个操作记录文件。修复未写完的尾行时保留当前段的完整行与上一段。
版本豁免变更复用 bundle 启停记录字段。豁免版本与安装版本相同时,记录无法区分豁免变更与 bundle 开关。
Host 的 `transportDropped` 只表示最近一次成功发送时的计数下界;Desktop 拒绝的非法或运行 ID 不匹配记录不计入该值。
操作历史保留两个 256 KiB 文件段,单条记录不超过 4 KiB,排队写入不超过 64 KiB。
退出、恢复重启和安装准备最多等待排队写入 500 ms;超时会停用后续写入,此后的排空调用立即返回,不会再次等待;但已经发出的操作系统请求仍可能随后结束。
记录覆盖 Host 启动后观察到的管理器操作,不包含直接执行的 CLI 命令或手工文件修改。包管理器输出保留在原有详细日志中,不复制到摘要。缺失记录、只有开始没有结束的操作以及损坏的历史,都不能说明操作已成功。此收集不诊断内核卡死、OOM 或原生崩溃。
持久化失败也会在本次壳生命周期内停用写入,包括恢复或安装器失败后继续运行的情况,以限制重复失败的 I/O,并避免在可能残缺的写入后追加记录。重启应用会创建新写入器。遗漏和修复计数仅覆盖当前壳生命周期。保留文件段提供比报告附件更大的本地排障历史窗口。
<a id="collection-and-shutdown"></a>
## 采集与退出行为
基本报告保存后,壳会尝试通过独立 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)。
+2
View File
@@ -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:*"
}
}
@@ -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',
+5
View File
@@ -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')
}
+51 -23
View File
@@ -12,10 +12,14 @@ interface RecoveryOperations {
disablePlugins(): Promise<void>
exit(): void
restart(): void
/** Reveal an existing report in the system file manager; reject if it is no longer available. */
revealReport(path: string): Promise<void>
/** Persist the complete diagnostic; resolves with the file path, or `undefined` when nothing was written. */
writeReport(error: unknown, source: CrashReportSource): Promise<string | undefined>
}
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<string | undefined> {
let timer: ReturnType<typeof setTimeout> | undefined
private async persist(error: unknown, source: CrashReportSource): Promise<void> {
try {
return await Promise.race([
this.operations.writeReport(error, source),
new Promise<undefined>((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<void>): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
report,
new Promise<void>((resolve) => { timer = setTimeout(resolve, CRASH_REPORT_WAIT_MS) }),
])
} finally {
clearTimeout(timer)
}
+26 -2
View File
@@ -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<string, unknown>
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)
+38
View File
@@ -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)
}
}
+40
View File
@@ -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',
+90 -19
View File
@@ -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<void> => {}
let stopForRecovery = async (): Promise<void> => { 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<OperationReportJobResult> | undefined
let operationReportAbort: AbortController | undefined
let operationReportsStopping = false
let operationReportStop: Promise<void> | undefined
/** Bound application shutdown waiting without claiming that an unresponsive Worker has exited. */
function stopOperationReports(): Promise<void> {
operationReportStop ??= waitForOperationReports()
return operationReportStop
}
async function waitForOperationReports(): Promise<void> {
operationReportsStopping = true
operationReportAbort?.abort()
const job = operationReportJob
if (job === undefined) return
let timer: ReturnType<typeof setTimeout> | undefined
try {
const exited = await Promise.race([job.then(() => true), new Promise<false>((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<string | undefined> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> => {
startup ??= (async () => {
@@ -651,14 +713,17 @@ async function main(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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)
})
+120
View File
@@ -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<OperationHistoryReadResult> {
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<string>()
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() }
}
+208
View File
@@ -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<void> | 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<void> {
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<boolean> {
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new RangeError('Invalid journal drain deadline')
if (this.disabled) return false
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
(async () => {
while (this.writing !== undefined) await this.writing
return !this.disabled
})(),
new Promise<false>((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<void> {
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<void> {
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()
}
}
+106
View File
@@ -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<OperationReportJobResult> {
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<typeof setTimeout> | 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()
})
}
@@ -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()
}
+62
View File
@@ -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<boolean> {
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
}
}
+15 -12
View File
@@ -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
+15 -1
View File
@@ -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)
<a id="verification-diagnostics"></a>
## 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.
<a id="verification-theme"></a>
## Update dialog themes
+15 -1
View File
@@ -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)
<a id="verification-diagnostics"></a>
## 故障报告验证
产品行为与限制见[故障恢复与诊断](../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 退出,并逐字节核对基本报告。该方法不模拟内核系统调用卡死,也不代替打包客户端验证。
<a id="verification-theme"></a>
## 更新弹窗主题
@@ -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
@@ -1,5 +1,6 @@
DeepSeek Harness 无法使用
应用无法启动或已意外停止。
有其他正在运行的 DSH(如其他 dsh web、桌面端),无法同时启动,请退出其他正在运行的 DSH 后重启。
诊断报告未能保存,反馈问题时可提供此窗口的截图。
退出
重启
@@ -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
@@ -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
@@ -8,3 +8,4 @@ Plugin initialization failed
退出
重启
禁用第三方插件、备份 profile patch 并重启
获取诊断报告
@@ -2,6 +2,7 @@ DeepSeek Harness 无法使用
应用无法启动或已意外停止。
Desktop Host failed
Plugin initialization failed
诊断报告未能保存,反馈问题时可提供此窗口的截图。
如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
退出
@@ -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 ---
+115 -5
View File
@@ -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<string>()
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<string | undefined>()
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)
})
+83 -1
View File
@@ -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<Error>()
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()
+62
View File
@@ -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()
})
+142 -13
View File
@@ -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<void>>(), 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<typeof import('../src/crash-report.ts')>(),
writeCrashReport: vi.fn(async () => 'desktop-test-logs/crash-test.log'),
writeCrashReport: vi.fn<(...args: unknown[]) => Promise<string | undefined>>(),
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<typeof DesktopHostProcess>[8],
readonly onPluginOperation?: ConstructorParameters<typeof DesktopHostProcess>[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<typeof fetch>().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<void>((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) {
@@ -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<typeof import('node:fs/promises')>()
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<undefined>()
const release = Promise.withResolvers<undefined>()
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)
})
@@ -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<typeof import('node:fs/promises')>()
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<undefined>()
const release = Promise.withResolvers<undefined>()
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')
})
@@ -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()
})
@@ -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')
})
@@ -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')
})
@@ -125,6 +125,42 @@ async function packageFixture(input: Awaited<ReturnType<typeof fixture>>) {
}).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<string, Stats>()
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)
+3
View File
@@ -35,6 +35,9 @@
{
"path": "../../packages/client/product-analytics/tsconfig.host.json"
},
{
"path": "../../packages/boot/plugin-manager"
},
{
"path": "../../packages/util/file-drag"
}
+6
View File
@@ -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 })],
+18 -18
View File
@@ -1,9 +1,9 @@
/**
* Scroll continuity when "On next message" folds a process group the reader opened. The latest Turn has
* one Step; the reader opened its group and expanded the reasoning inside, so the group body is capped and
* scrollable, then sends the next message from the floor. A held prompt keeps the new Turn from starting
* until the fold settles, so only the fold moves the view while the scrollport is sampled on every
* animation frame.
* scrollable, then sends the next message from the floor. Agent maintenance keeps the accepted message in
* the inbox, so its echo folds the group before the new Turn starts, and only the fold moves the view while
* the scrollport is sampled on every animation frame.
*
* - A long answer leaves the folded group above the viewport. The scroll position changes in one direction
* only: the fold never scrolls the view away and then snaps it back.
@@ -13,7 +13,7 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page, Route } from 'playwright'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -223,8 +223,8 @@ interface World {
readonly page: Page
readonly session: SessionId
readonly tripwire: ReturnType<typeof watchConsole>
/** Releases the held prompt; the new Turn starts after this. */
readonly releasePrompt: () => void
/** Ends the Agent maintenance that keeps the accepted message in the inbox; the new Turn starts after this. */
readonly releaseTurn: () => void
/** Releases the held model stream; the new Step's output arrives after this. */
readonly releaseModel: () => void
readonly ended: Promise<void>
@@ -232,8 +232,8 @@ interface World {
}
/**
* Seed and open the case, open the latest group, and send the next message from the floor with the prompt
* and the model stream held.
* Seed and open the case, open the latest group, and send the next message from the floor with the new Turn
* and its model stream held.
*/
async function sendFromOpenedGroup(browser: Browser, id: string, answerParagraphs: number): Promise<World> {
const replayDir = await mkdtemp(join(tmpdir(), 'dsh-open-fold-'))
@@ -249,17 +249,15 @@ async function sendFromOpenedGroup(browser: Browser, id: string, answerParagraph
await model.promise
for await (const chunk of next()) yield chunk
})
const prompt = Promise.withResolvers<undefined>()
const holdPrompt = async (route: Route): Promise<void> => {
await prompt.promise
await route.continue()
}
const turn = Promise.withResolvers<undefined>()
let maintenance: Promise<undefined> = Promise.resolve(undefined)
const page = await newEnglishPage(browser, 826)
const close = async (): Promise<void> => {
prompt.resolve(undefined)
turn.resolve(undefined)
model.resolve(undefined)
disposeHold()
const failures: unknown[] = []
await maintenance.catch((error: unknown) => failures.push(error))
await page.context().close().catch((error: unknown) => failures.push(error))
await scaffold.close().catch((error: unknown) => failures.push(error))
await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
@@ -280,7 +278,9 @@ async function sendFromOpenedGroup(browser: Browser, id: string, answerParagraph
await toBottom(page)
await openLatestGroup(page)
await toBottom(page)
await page.route('**/api/session/prompt', holdPrompt)
const resolved = await scaffold.ctx.sessionController.resolveAgent(session)
if ('error' in resolved) throw resolved.error
maintenance = resolved.agent.runMaintenance(() => turn.promise)
const ended = new Promise<void>((resolveEnded, reject) => {
const timer = setTimeout(() => { off(); reject(new Error(`${id}: no turn/end within 60000ms`)) }, 60_000)
const off = scaffold.ctx.on('session/event', (owner: { id: SessionId }, event: SessionEvent) => {
@@ -299,7 +299,7 @@ async function sendFromOpenedGroup(browser: Browser, id: string, answerParagraph
await stableScroll(page)
return {
scaffold, page, session, tripwire, ended, close,
releasePrompt: () => { prompt.resolve(undefined) },
releaseTurn: () => { turn.resolve(undefined) },
releaseModel: () => { model.resolve(undefined) },
}
} catch (error) {
@@ -330,7 +330,7 @@ describe.skipIf(MODE === 'record')('web e2e: next-message fold of an opened proc
expect.soft(reversals, `scroll direction changes; per-frame movements ${JSON.stringify(steps)}`).toBe(0)
const settled = frames.at(-1)!
expect.soft(settled.floor - settled.top, 'distance from the floor after the fold').toBeLessThanOrEqual(1)
world.releasePrompt()
world.releaseTurn()
world.releaseModel()
await world.ended
expect(world.tripwire.pageErrors).toEqual([])
@@ -345,7 +345,7 @@ describe.skipIf(MODE === 'record')('web e2e: next-message fold of an opened proc
const folded = (await readTrace(world.page)).frames.at(-1)!
expect.soft(folded.spacer, 'fold room below the short answer').toBeGreaterThan(0)
expect.soft(folded.pinned, 'tail pin while fold room remains').toBe(false)
world.releasePrompt()
world.releaseTurn()
// The running Step's group row is the first row after the user message; output stays held until then.
await world.page.locator(`${ROOT} [data-chat-group-key][data-chat-turn="${String(TURNS + 1)}"]`).waitFor({ timeout: 30_000 })
await world.page.locator(`${ROOT} [data-chat-running]`).waitFor()
+8 -10
View File
@@ -6,7 +6,7 @@ import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page, Route } from 'playwright'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -679,14 +679,11 @@ describe('web e2e: long Chat scroll contract', () => {
yield chunk
}
})
const resolved = await world.scaffold.ctx.sessionController.resolveAgent(SessionId(`visible-fold-${scenario.name}`))
if ('error' in resolved) throw resolved.error
// Maintenance keeps the accepted message in the inbox, so its echo folds the earlier work before the Turn starts.
const promptGate = Promise.withResolvers<undefined>()
const promptBlocked = Promise.withResolvers<undefined>()
const holdPrompt = async (route: Route): Promise<void> => {
promptBlocked.resolve(undefined)
await promptGate.promise
await route.continue()
}
await page.route('**/api/session/prompt', holdPrompt)
const maintenance = resolved.agent.runMaintenance(() => promptGate.promise)
const trace = page.evaluate(({ marker, race }) => new Promise<FoldMotionSample[]>((resolveTrace) => {
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')!
const column = host.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!
@@ -781,7 +778,7 @@ describe('web e2e: long Chat scroll contract', () => {
await page.clock.resume()
}
} else await page.getByRole('button', { name: 'Send message', exact: true }).click()
await promptBlocked.promise
await expect.poll(() => page.locator('[data-composer-input]').last().textContent()).toBe('')
await page.getByText(input, { exact: true }).waitFor()
await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0)
if (scenario.name !== 'cancel-all-during-fold') await expectBottom(page)
@@ -801,6 +798,7 @@ describe('web e2e: long Chat scroll contract', () => {
})
await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-growth')))
promptGate.resolve(undefined)
await maintenance
release()
await finishBlocked.promise
await page.getByText('VISIBLE_FOLD_DONE', { exact: false }).waitFor()
@@ -808,8 +806,8 @@ describe('web e2e: long Chat scroll contract', () => {
await nextPaint(page)
} finally {
promptGate.resolve(undefined)
await maintenance
release()
await page.unroute('**/api/session/prompt', holdPrompt)
await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop')))
samples = await trace
finishGate.resolve(undefined)
+160
View File
@@ -0,0 +1,160 @@
// Keyless assembled-browser coverage for the opt-in Statistics view: the
// experimental client plugin is mounted only by the test overlay, so this Web
// composition is the package's user-visible evidence outside the jsdom lane.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { readFileSync } from 'node:fs'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import {
createAssistantMessage, createMessage, createUserMessage, ToolCallId,
type AssistantStreamRecord, type TokenUsage,
} from '@deepseek-ai/dsh-llm'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import {
connectFreshWorkspace, newEnglishPage, pinBrowserClock, pinHostClock, saveFailureShot,
} from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/client-ui-stats-panel', import.meta.url))
const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'panel.expected.md')
const OVERLAY = fileURLToPath(new URL('./client-ui-stats-panel.overlay.yml', import.meta.url))
const INSTALL_ANCHORS = [
fileURLToPath(new URL('../../../packages/experimental/client-ui-stats/package.json', import.meta.url)),
]
const MODE = webSnapshotMode()
/** Overlay layer entries, for the composition guard below. */
function profileEntries(path: string): unknown[] {
const parsed = yaml.load(readFileSync(path, 'utf8'), { schema: entryListSchema })
if (!Array.isArray(parsed)) throw new Error(`profile layer at ${path} must be a list`)
return parsed
}
/** Usage one fixture assistant round reports. */
const USAGE: TokenUsage = {
inputTokens: 1_200,
outputTokens: 240,
cacheReadTokens: 3_600,
cacheWriteTokens: 400,
}
/** Compact stream whose first token arrives at the absolute Session time `firstTokenAt`. */
function stream(firstTokenAt: number): AssistantStreamRecord[] {
return [{ type: 'text-chunks', time0: firstTokenAt, index: 0, dt: [200], texts: ['Ready.'] }]
}
describe('Statistics view overlay', () => {
it('mounts the experimental client plugin over the shipped Web surface', () => {
expect(profileEntries(OVERLAY)).toEqual([{
insert: [{ id: 'ui-client-ui-stats', name: '@deepseek-ai/dsh-experimental-client-ui-stats' }],
}])
})
})
describe('web e2e: Statistics view', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let clock: ReturnType<typeof pinHostClock>
beforeAll(async () => {
clock = pinHostClock()
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, extraInstallAnchors: INSTALL_ANCHORS })
browser = await chromium.launch()
page = await newEnglishPage(browser)
await pinBrowserClock(page)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const agent = scaffold.ctx.agents.list()[0]
if (agent === undefined) throw new Error('connected workspace did not create an Agent')
agent.session.append('turn/start', { turn: 1 })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Show the measured round.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
agent.session.append('step/start', { turn: 1, step: 1 })
await new Promise((resolve) => { setTimeout(resolve, 60) })
// Real timestamps: the compact stream's time0 is an absolute Session time,
// so the measured first token must sit between this step's start and the
// settled message, exactly as a live round records them.
const firstTokenAt = Date.now() - 10
agent.session.append('assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'Ready.' }],
source: { provider: 'fixture', model: 'fixture' },
}),
stream: stream(firstTokenAt),
usage: USAGE,
}, { surfaceOp: 'append' })
agent.session.append('tool/call', {
turn: 1,
step: 1,
callId: ToolCallId('call-1'),
name: 'read',
arguments: JSON.stringify({ file_path: '/work/notes.md' }),
})
agent.session.append('tool/result', {
turn: 1,
step: 1,
message: createMessage({
role: 'tool',
content: [{ type: 'text', text: 'one\ntwo' }],
source: { kind: 'tool', callId: ToolCallId('call-1') },
toolCallId: ToolCallId('call-1'),
}),
}, { surfaceOp: 'append' })
agent.session.append('step/end', { turn: 1, step: 1 })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await scaffold.ctx.sessions.flush(agent.session)
await page.getByText('Ready.').waitFor({ timeout: 10_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
clock?.mockRestore()
await scaffold?.close()
})
it('renders the measured context, timing, cache, and tool columns', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-client-ui-stats-panel'))
await page.getByRole('tab', { name: 'Statistics', exact: true }).click()
const table = page.locator('table[aria-label="Statistics"]')
await table.waitFor({ timeout: 10_000 })
expect(await table.getByRole('columnheader', { name: 'Context', exact: true }).count()).toBe(1)
expect(await table.getByRole('columnheader', { name: 'Output', exact: true }).count()).toBe(1)
expect(await table.getByRole('columnheader', { name: 'Cache hit', exact: true }).count()).toBe(1)
expect(await table.getByText('Turn 1', { exact: false }).count()).toBeGreaterThan(0)
expect(await table.getByText('Step 1', { exact: false }).count()).toBeGreaterThan(0)
expect(await table.getByText('read ×1', { exact: false }).count()).toBeGreaterThan(0)
// The measured columns carry the fixture's real accounting, and the timing
// columns are measured rather than the dash placeholder. Durations and the
// derived throughput/clock vary with the run, so the golden tokenizes them.
const cells = await table.locator('tbody tr').first().locator('td').allInnerTexts()
expect(cells[3]).toBe('5.2K')
expect(cells[4]).toBe('0.24K')
expect(cells[5]).toBe('69.23%')
expect(cells[6]).not.toBe('—')
expect(cells[8]).not.toBe('—')
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
const snapshot = await captureStableAria(page, 'table[aria-label="Statistics"]', scaffold.workspaceCwd, {
replacements: [[cells[6]!, '{{ttft}}'], [cells[8]!, '{{tps}}']],
})
await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE)
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['panel.expected.md'])
})
})
@@ -0,0 +1,7 @@
# Test-only composition: mount the experimental @@STATSORMANCE@@ view beside Chat
# and Trajectory over the shipped Web surface. No shipped profile mounts this
# package (default-product isolation forbids it), so this overlay is the only
# real-Web composition that can render it.
- insert:
- id: ui-client-ui-stats
name: '@deepseek-ai/dsh-experimental-client-ui-stats'

Some files were not shown because too many files have changed in this diff Show More