Merge latest master into Office progressive skills

This commit is contained in:
yudshj committed 2026-10-12 12:50:33 +08:00
commit b6fc04f403
1458 files changed
+29668 -5360

No files matched your search

@@ -33,8 +33,8 @@
en: a244f6a88fdabf3d
zh: e4feedb7297f4865
/agent-note-typert-gateway-targeted-method-calls/three-artifact-kinds-and-two-typescript-programs:
en: 6ea4bddcf35a834d
zh: 4af474c6818b1cf0
en: c5bcd6eb76bf3d42
zh: 9df2fbb364643186
/agent-note-typert-gateway-targeted-method-calls/the-remote-package-entry:
en: 75e977fe0fb71d59
zh: 00760c79da173cb7
@@ -215,7 +215,7 @@ Host lib build
→ Vite 构建 Web
```
The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build.
The top-level `build` runs the step graph in `scripts/build.ts`, which completes the Host bundle and the Remote artifacts before starting Client TypeScript compilation. `build:lib` remains the complete serial lib build for `build:bench`, and its Host half `build:lib:host` serves the vendor release workflows. `typecheck` writes the Remote artifacts through `scripts/generate-typert.ts`, beside Host compilation, before Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build.
Compiler-backed repository gates that resolve the consumer surface have the same prerequisite even when their primary inputs are source files. The public `typecheck`, `lint`, and `doc-typecheck` commands run the Host contract pass first. The gate scheduler may use their `*:contracts-ready` variants only after an explicit Typert-contract or complete-build dependency, so parallel lanes neither read missing declarations nor run concurrent generators against the same outputs.
@@ -215,7 +215,7 @@ Host lib build
→ Vite 构建 Web
```
现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。
顶层 `build` 运行 `scripts/build.ts` 中的步骤图,先完成 Host bundle 与 Remote artifact,再启动 Client TypeScript 编译。`build:lib` 仍是 `build:bench` 使用的完整串行 lib 构建,其 Host 部分 `build:lib:host` 供 vendor 发布 workflow 使用。`typecheck` 与 Host 编译并行,经 `scripts/generate-typert.ts` 写出 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。
即使主要输入是源文件,需要通过编译器解析消费方 surface 的仓库门禁也有相同的前置条件。公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先执行 Host 约定 pass。门禁调度器仅可在显式的 Typert 约定依赖或完整构建依赖完成后使用对应的 `*:contracts-ready` 变体,使并行 lane 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。
@@ -9,11 +9,11 @@
en: 9f34c53e647c69ca
zh: 551776c7cc9618b5
/agent-note-client-derived-presentation-from-raw-session-tool-events/decision:
en: bdbb99c01b4c4db0
zh: 570040471ad1f7f4
en: 5fcc5a3de9f757cd
zh: a693cefdb82784ee
/agent-note-client-derived-presentation-from-raw-session-tool-events/decision/goals-and-non-goals:
en: 7e7a937dc4fe1b18
zh: 530f508b7e17b607
en: 40aceb58d71303b5
zh: 6954e41ae2fd1efd
/agent-note-client-derived-presentation-from-raw-session-tool-events/terminology:
en: 021106dd41176715
zh: 131caff7bd37251b
@@ -72,11 +72,11 @@
en: f40f0a8ee8c055c0
zh: 6957b7372956113c
/agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/generic-tool-row:
en: 20ff82b092325c5c
zh: 1afd5441a9816117
en: d336c77d1b09f349
zh: 6e613f9c9108058f
/agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/terminal-card:
en: eab7f366df331b6e
zh: d82499e2a982206f
en: 492669c2134169b2
zh: 0247a38df2dd8a83
/agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/diff-card:
en: ea384415c94f82da
zh: 5f146c6cb534e1db
@@ -99,8 +99,8 @@
en: e5b42aa311079bd7
zh: ec4cf97037ece7e0
/agent-note-client-derived-presentation-from-raw-session-tool-events/presentation-equivalence-matrix:
en: 30915c26728eeafe
zh: eea04dea7b4604a5
en: 7974f20a6ac687ac
zh: cb4c350325a6da67
/agent-note-client-derived-presentation-from-raw-session-tool-events/client-extension-contract:
en: 96619dfd5ef299cf
zh: 253df98eb42ad75e
@@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner
## Decision
The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../../../../packages/client/ui-tool/README.md) and [recorded tool details](../../../../packages/client/ui-tool/README.md); all other presentation and ownership constraints remain.
The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../../../../packages/client/ui-tool/README.md), [recorded tool details](../../../../packages/client/ui-tool/README.md), and [background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md); all other presentation and ownership constraints remain.
The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view.
@@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall
| Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology |
| Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure |
| Forbidden | a new Client presenter service, parallel registry, or wire renderer id |
| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../../../../packages/client/ui-tool/README.md) and [recorded tool details](../../../../packages/client/ui-tool/README.md) |
| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../../../../packages/client/ui-tool/README.md), [recorded tool details](../../../../packages/client/ui-tool/README.md), and [background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md) |
| Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field |
## Terminology
@@ -294,15 +294,16 @@ Unknown tools continue to use `GenericToolCard` with the name, raw arguments, re
The title, kind, rawInput, content, and locations from Generic Host `presentCall` do not currently drive an ordinary Web row. Generic `presentResult.content` also does not drive Web output, so the Client need not copy these unconsumed values.
<a id="terminal-card"></a>
### Terminal card
The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, and Session cwd, independently of `parentCallId`.
The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, and Session cwd, independently of `parentCallId`. [Background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md) governs explicit background `bash`/`pwsh` launches; the remaining ownership and fallback rules below stay in force.
| Input | Preserved result |
|---|---|
| running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state |
| successful standard foreground call | terminal output, exit code/signal, and settled state presentation |
| `run_in_background:true` | Generic row and raw result |
| explicit background standard `bash`/`pwsh` | original `background` flag with call `running` at Start; acknowledgement clears `running` and retains a static command without output or job status |
| tool execution error | Generic IN/OUT and error summary |
| running persistent `bash`/`pwsh` | terminal prompt |
| settled persistent `bash`/`pwsh` | Generic flattened result, with no new exit card |
@@ -310,7 +311,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n
| background/error `terminal_send` | Generic result |
| PTC dispatch child | same terminal eligibility and fallback rules as a root call |
Standard shell results parse trailing `[exit code: N]` and `[killed by signal: X]` markers. A final recognized spill-policy notice selects Generic output instead: expandable in shell rows and raw in Details, because the exit marker may be displaced or omitted. A parsed marker is removed from the terminal body; timeout, sandbox denial, and markers without a pill remain in the body.
Foreground standard shell results parse trailing `[exit code: N]` and `[killed by signal: X]` markers; explicit background launch acknowledgements do not enter output or exit-status parsing. A final recognized spill-policy notice selects Generic output instead: expandable in shell rows and raw in Details, because the exit marker may be displaced or omitted. A parsed marker is removed from the terminal body; timeout, sandbox denial, and markers without a pill remain in the body.
Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots.
@@ -318,7 +319,7 @@ For `terminal_send`, non-empty input and the session id remain verbatim tool dat
Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation.
`TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged.
Outside the explicit background command-only presentation, `TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged.
### Diff card
@@ -412,7 +413,7 @@ The scenario does not import Host tool packages to compute page presentation and
| orphan result | callId title and Generic output |
| interrupted call | warning/stopped state |
| foreground bash/pwsh | current terminal prompt, body, cwd, and state |
| background/error bash/pwsh | current Generic IN/OUT |
| explicit background bash/pwsh | terminal Start and static command-only acknowledgement; launch errors retain Generic IN/OUT |
| persistent shell | current running terminal and settled Generic form |
| terminal_send | current foreground terminal and background/error Generic form |
| write/edit | current intended/applied diff and error fallback |
@@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的
## Decision
下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../../../../packages/client/ui-tool/README.zh.md)和[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md);其他展示与所有权约束全部保留。
下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../../../../packages/client/ui-tool/README.zh.md)、[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)和[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md);其他展示与所有权约束全部保留。
Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。
@@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa
| 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology |
| 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 |
| 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id |
| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../../../../packages/client/ui-tool/README.zh.md)和[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)除外 |
| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../../../../packages/client/ui-tool/README.zh.md)、[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)和[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md)除外 |
| 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 |
## 术语
@@ -294,15 +294,16 @@ Chat 与 Details 通过纯 helper 复用同一 block 的解析。未来缓存必
Generic Host `presentCall` 的 title、kind、rawInput、content 与 locations 当前并不驱动普通 Web 行;Generic `presentResult.content` 也不驱动 Web 输出,因此无需把这些未消费值复制到 Client。
<a id="terminal-card"></a>
### Terminal 卡片
Client terminal model 从工具名称、调用参数、结果 content、error 与 Session cwd 派生现有 `TerminalBlock` props,不依赖 `parentCallId`。
Client terminal model 从工具名称、调用参数、结果 content、error 与 Session cwd 派生现有 `TerminalBlock` props,不依赖 `parentCallId`。[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md)拥有主动后台 `bash`/`pwsh` 启动的展示决定;下述其余所有权和 fallback 规则继续有效。
| 输入 | 保持的结果 |
|---|---|
| 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 |
| 标准前台 success | terminal output、exit code/signal 与结算状态呈现 |
| `run_in_background:true` | Generic 行与原始结果 |
| 标准 `bash`/`pwsh` 主动后台调用 | Start 使用原 `background` 标志和调用 `running`;回执清除 `running` 后保留静态命令,不显示输出或任务状态 |
| 工具执行 error | Generic IN/OUT 与错误摘要 |
| persistent `bash`/`pwsh` running | terminal prompt |
| persistent `bash`/`pwsh` settled | Generic flattened result,不新增 exit card |
@@ -310,7 +311,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error
| `terminal_send` background/error | Generic 结果 |
| PTC dispatch child | 与根调用相同的 terminal 适用规则与 fallback 规则 |
标准 shell 结果解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。末尾已识别的 spill 策略提示会改用 Generic 输出:在 shell 行中可展开,在 Details 中显示原文,因为退出标记可能被移位或省略。已解析的 marker 从 terminal 正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
标准前台 shell 结果解析末尾 `[exit code: N]` 与 `[killed by signal: X]`;主动后台启动回执不进入输出或退出状态解析。末尾已识别的 spill 策略提示会改用 Generic 输出:在 shell 行中可展开,在 Details 中显示原文,因为退出标记可能被移位或省略。已解析的 marker 从 terminal 正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。
@@ -318,7 +319,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error
同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。
TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。
除主动后台的仅命令展示外,TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本保持不变。
### Diff 卡片
@@ -412,7 +413,7 @@ Deliverables Definition 按 callId 观察 root `tool/call` 与成功 `tool/resul
| orphan result | callId 标题与 Generic output |
| interrupted call | warning/stopped 状态 |
| bash/pwsh 前台 | 当前 terminal prompt、正文、cwd 与状态 |
| bash/pwsh background/error | 当前 Generic IN/OUT |
| bash/pwsh 主动后台调用 | terminal Start 与静态仅命令回执展示;启动错误保留 Generic IN/OUT |
| persistent shell | 当前 running terminal、settled Generic |
| terminal_send | 当前前台 terminal、后台/error Generic |
| write/edit | 当前 intended/applied diff 与 error fallback |
@@ -24,8 +24,8 @@
en: e9f140cd22670dfa
zh: bf85cfe9c12a0345
/agent-note-package-and-update-the-electron-desktop-application/security-and-release-policy:
en: 97ee608e96fc3602
zh: 932b98e7d344cd87
en: 36559f9179764da7
zh: f1d39a6438ca520c
/agent-note-package-and-update-the-electron-desktop-application/implementation:
en: 041a6d35ed61b149
zh: 617c6b90a6f4fa4d
@@ -81,7 +81,7 @@ Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file.
Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles.
Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap.
Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. Windows packaging can instead select a machine-owned public `presets/<preset>.env`, plus `secrets/windows-signing.env` for signed builds; unsigned and preparation-only modes omit the signing file. The selected files exclusively own release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap.
The [pinned osx-sign patch](../../../../patches/@[email protected]) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup.
@@ -81,7 +81,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。
Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。Windows 打包也可选择机器管理的公开 `presets/<preset>.env`,签名构建另读 `secrets/windows-signing.env`;未签名和仅准备模式不读取签名文件。所选文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。
[固定版本的 osx-sign 补丁](../../../../patches/@[email protected])在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
@@ -9,8 +9,8 @@
en: 66ac3d7ce7eb8e15
zh: abb9d41b71900bbf
/agent-note-sandboxed-node-execution-for-ptc/decision:
en: cf072f53de09faca
zh: 9edd5c4a2bfaae5a
en: d6b9c22603a4f933
zh: f324b91830078d9a
/agent-note-sandboxed-node-execution-for-ptc/decision/resolved-inputs-and-policy:
en: 3aef37bb4f8a8e53
zh: a7b477324d486ab3
@@ -18,8 +18,8 @@
en: 2c7a694b04951772
zh: 133b95b0477b127f
/agent-note-sandboxed-node-execution-for-ptc/decision/resource-limits:
en: 84c73cf3ff33bc03
zh: a228080683d6252c
en: 3f1d8a30f6d48243
zh: faa7c01d7c959893
/agent-note-sandboxed-node-execution-for-ptc/alternatives-considered:
en: 8ffcc2b32a2243d7
zh: 81d48d38947abc76
@@ -12,7 +12,7 @@ The [PTC foundation](../feature/2026-06-15-ptc.md) remains responsible for regis
## Decision
`dsh-ptc-runtime-node` runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains inside this provider.
`dsh-ptc-runtime-node` in its default `per-call` mode runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains after a per-call execution.
The host preserves `ELECTRON_RUN_AS_NODE` for child startup; the bootstrap removes it from the native environment before evaluation, and model-visible `process.env` stays empty. Nested Electron launches require their own explicit Node-mode selection. Desktop uses Electron as its Node executable; removing this selector launches Electron's application path instead of the PTC bootstrap. Sandbox permission changes cannot repair that launch mismatch. The macOS Desktop regression uses real Electron to verify binding writes, direct workspace writes, and rejection of writes outside the workspace under restricted policy. It requires an installed Electron binary; ordinary runtime tests cover environment filtering without that dependency.
@@ -34,6 +34,8 @@ Program completion, timeout, cancellation and protocol failure all close executi
The default elapsed deadline is 120 seconds, capped at 600 seconds by default. Trusted service consumers may request `timeoutMs: null` to disable this timer; [workflow sandbox reuse](2026-09-13-workflow-ptc-sandbox-reuse.md) owns that caller-controlled lifetime. Omitted and numeric requests, including model-facing `run_code`, retain the numeric defaults and caps. An enabled deadline includes runtime setup and nested tool or approval waits. V8 old-generation memory, serialized outer output and control traffic have separate configured bounds. The heap limit excludes native allocations and descendant memory, and elapsed time is not a process-tree CPU budget.
The opt-in [in-memory execution mode](2026-09-23-best-effort-persistent-ptc.md) retains disposable memory under the same file policy; [retained-process permissions](2026-10-08-persistent-ptc-sandbox.md) govern policy changes and temporary escalation.
## Alternatives considered
**Keep the worker and add tool checks.** Tool checks cannot intercept direct Node imports or establish OS confinement. Keeping a worker inside a confined supervisor process preserves worker metering but adds another execution lifetime without supplying a process-tree CPU limit.
@@ -12,7 +12,7 @@ Node worker 隔离 JavaScript 状态,但不应用调用 Session 的 OS 沙箱
## 决策
`dsh-ptc-runtime-node` 在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。本提供方不保留 worker 或持久内核。
`dsh-ptc-runtime-node` 的默认 `per-call` 模式在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。逐次执行结束后不保留 worker 或持久内核。
Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前将其从原生环境中删除,模型可见的 `process.env` 仍为空。嵌套启动 Electron 需要自行显式选择 Node 模式。桌面端使用 Electron 作为 Node 可执行文件;删除此选择变量会启动 Electron 应用路径,而不是 PTC bootstrap。更改沙箱权限无法修复这一启动模式不匹配。macOS 桌面端回归测试使用真实 Electron 验证绑定写入、直接工作区写入以及受限策略对工作区外写入的拒绝。该测试需要已安装的 Electron 二进制文件;普通运行时测试无需此依赖即可覆盖环境过滤。
@@ -34,6 +34,8 @@ Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前
默认经过时间截止为 120 秒,默认上限为 600 秒。可信服务消费方可以请求 `timeoutMs: null` 来禁用该定时器;[工作流沙箱复用](2026-09-13-workflow-ptc-sandbox-reuse.zh.md)负责这种由调用方控制的生命周期。省略 timeout 或使用数值的请求(包括面向模型的 `run_code`)保留数值默认值与上限。启用的截止包括运行时准备以及嵌套工具或审批等待。V8 老生代内存、序列化外层输出与控制通信具有独立配置的上限。堆限制不包含原生分配和后代内存,经过时间也不是进程树 CPU 预算。
显式启用的[内存保留执行模式](2026-09-23-best-effort-persistent-ptc.zh.md)在相同文件策略下保留可丢弃内存;[常驻进程权限](2026-10-08-persistent-ptc-sandbox.zh.md)负责策略切换和临时提权。
## 考虑过的替代方案
**保留 worker 并增加工具检查。** 工具检查不能拦截直接 Node 导入,也不能建立 OS 约束。在受限监督进程中保留 worker 可以保留 worker 计量,但会增加一层执行生命周期,仍不能提供进程树 CPU 限制。
@@ -9,14 +9,14 @@
en: 837ebb0071a5611b
zh: 23d0f0713889ead6
/agent-note-writer-acquisition-for-blank-session-reuse/decision:
en: 7f2676feb224e7ea
zh: babfda5b31dfd0fe
en: a54db0d4a97aa9b8
zh: a52d257b657fe99b
/agent-note-writer-acquisition-for-blank-session-reuse/alternatives-considered:
en: 2a67a53f7f396ba8
zh: 6df6ccc071fb0de2
/agent-note-writer-acquisition-for-blank-session-reuse/consequences:
en: 2e1f1894337689a2
zh: d0cab07cc48083ee
en: 8307612b7476e8f3
zh: 87fa39e58d33d4f1
/agent-note-writer-acquisition-for-blank-session-reuse/verification:
en: 4680e845e2ab294e
zh: ea5890c5ffb09831
en: 4367d55ae9dce5c1
zh: c610626f4e15eb77
@@ -10,7 +10,7 @@ Two Hosts sharing Session storage can select the same blank Session when opening
## Decision
The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` resumes that Session and acquires its writer before opening history. Only `session/writer-held` triggers creation of a new Session. Other errors propagate. Concurrent Workspace connects in one Client share the acquisition and fallback. Later navigation cancels a pending startup selection.
The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` names that Session; the Host resumes it and acquires its writer before opening history, and creates a new Session when another owner holds the writer ([reuse and preset rules](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Other errors propagate. A Workspace connect in one Client joins the latest in-flight connect to that Workspace only when both request the same preset. Later navigation cancels a pending startup selection.
The Agent retains the acquired handle throughout its lifetime. Checking ownership and releasing a probe lock would leave a race before resume. No PID or process identity enters persisted data or the Remote schema.
@@ -28,8 +28,8 @@ This decision extends the blank-reuse policy in the [Client Session scope refere
## Consequences
A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts New Session and is currently reported only to the console; hidden blanks have no recovery action in this flow.
A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts the navigation; a hero Workspace pick or explicit draft preparation reports it as a notice and startup restoration only to the console, and a hidden blank that fails this way has no recovery action in this flow.
## Verification
Navigation tests cover saved selection, single-candidate contention fallback, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt.
Navigation tests cover saved selection, opening the Session the Host returns for a held candidate, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt.
@@ -10,7 +10,7 @@ Status: implemented
## Decision
Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 会恢复该 Session,并在打开历史前取得写锁。只有 `session/writer-held` 会触发新建 Session。其他错误返回调用方。同一 Client 的并发 Workspace 连接共享获取与回退过程。后续导航会取消尚未完成的启动选择。
Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 指明该 Session;Host 恢复它并在打开历史前取得写锁,写锁被其他写者持有时改为新建 Session([复用与 preset 规则](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。其他错误返回调用方。同一 Client 中的 Workspace 连接仅在与该 Workspace 最近一次进行中的连接请求相同 preset 时与之合并。后续导航会取消尚未完成的启动选择。
Agent 在整个生命周期内持有取得的句柄。检查占用后释放探测锁,会在恢复前留下竞态。持久化数据与 Remote schema 均不引入 PID 或进程身份。
@@ -28,8 +28,8 @@ Agent 在整个生命周期内持有取得的句柄。检查占用后释放探
## Consequences
Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止新建会话,目前只报告到控制台;此流程没有针对隐藏空白会话的恢复操作。
Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止该次导航;在 hero 中选择工作区或显式准备草稿时以提示展示该错误,启动恢复只报告到控制台,以此方式失败的隐藏空白会话在此流程中没有恢复操作。
## Verification
导航测试覆盖已保存选择、单候选锁争用回退、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。
导航测试覆盖已保存选择、为被占用候选打开 Host 返回的 Session、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。
@@ -9,8 +9,8 @@
en: 725f773bb350218e
zh: dd8fbc09ef48208d
/agent-note-declarative-agent-presets-and-retained-revisions/decision:
en: 53fd72bf4bbe4eb3
zh: 406eee6e4f818e7c
en: 88edb925b556300c
zh: c6f05b641513d768
/agent-note-declarative-agent-presets-and-retained-revisions/alternatives-considered:
en: 09f40eab4659e9f6
zh: 7a70da0d0ae6845b
@@ -16,7 +16,7 @@ The registry owns each revision's scope and Loader tree. Agents link their own s
The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host.
Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md).
Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition; reusing a blank Session for a new conversation does not resolve it ([decision](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md).
## Alternatives considered
@@ -16,7 +16,7 @@ Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数
Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。
会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。
会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复;为新对话复用空白会话时不解析该标识([决策](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。
## Alternatives considered
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-23-best-effort-persistent-ptc.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-09-23-best-effort-persistent-ptc.md
/agent-note-node-ptc-execution-modes-and-in-memory-state:
en: 078a89753b06cd6a
zh: 4004e5e4bb42f2cc
/agent-note-node-ptc-execution-modes-and-in-memory-state/problem:
en: e42db40b879fdbf4
zh: 6017da9c4da17835
/agent-note-node-ptc-execution-modes-and-in-memory-state/decision:
en: d2d08c54c6520f02
zh: 520a9b3421ff5c65
/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
@@ -0,0 +1,37 @@
# Agent Note: Node PTC execution modes and in-memory state
Status: implemented
English | [中文](2026-09-23-best-effort-persistent-ptc.zh.md)
## Problem
Interactive automation retains application objects, imported modules and helper functions between model calls. Recreating every object for every program prevents consumers from using ordinary JavaScript references across observations and actions.
## Decision
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.
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.
The Session records submitted programs, nested tool calls and returned output. It does not serialize the REPL heap or rerun earlier programs after restart, resume or fork: replaying automation could repeat external side effects. A missing variable is a normal program error, and the model-visible instructions require recreating variables and reacquiring resources. No environment identifier or new durable Session event is required because the provider exposes no unlogged model input.
Cancellation, deadline expiry, protocol or output-limit failure, owner disposal and process exit discard the environment. Ordinary exceptions retain earlier mutations. A retained function uses the active evaluation's host bindings; asynchronous work from an earlier evaluation cannot acquire a later evaluation's tool authority or output sink.
## Alternatives considered
Separate npm providers plus a shared library allow independent distribution, at the cost of another package, dependency declarations and cross-package exports for shared implementation details. The two Node modes have no current need for separate release schedules or independent dependencies. A second plugin subpath permits separate loading but asks deployments to replace plugin entries to select an execution policy. A configuration field expresses that selection while preserving the existing entry. Independent distribution or substantially different dependencies can justify revisiting this choice.
A new Cordis service for common helpers would require another registration and injected dependency without adding an independently replaceable runtime capability. Internal functions and classes provide the required reuse. A common execution superclass would also couple fresh-process cleanup with owner-retained lifetimes; the provider shares mechanisms without merging those lifecycles.
Serializing arbitrary JavaScript objects cannot preserve native resources and closures. Replaying prior programs can repeat writes and input actions. Both would imply recovery guarantees that in-memory execution does not offer.
A realm inside the Harness process shares process lifetime with model code and cannot terminate a synchronous loop independently. A managed child process permits cancellation and resource cleanup while retaining imports and variables during healthy use. Process ownership alone does not provide a file sandbox or multi-tenant isolation.
## Consequences
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.
@@ -0,0 +1,37 @@
# Agent Note: Node PTC 执行模式与内存状态
Status: implemented
[English](2026-09-23-best-effort-persistent-ptc.md) | 中文
## 问题
交互式自动化需要在模型调用之间保留应用对象、导入模块和辅助函数。如果每次执行都重新创建对象,消费者就无法在观察与操作之间继续使用普通 JavaScript 引用。
## 决策
现有 `@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)负责文件约束、权限切换与临时提权。
一个 npm 包和一个插件入口保持现有安装与加载路径稳定。通信、绑定代理、JSON 编码和输出限制继续作为包内模块复用。Node 提供方将保留进程的所有权交给内部执行对象,该对象不注册额外的 Cordis 服务。逐次执行和内存保留各自管理不同的生命周期。`programFormat` 描述代码求值形式,与状态保留独立:REPL 不必跨调用保留状态。运行时通过 `executionInstructions` 负责语法、完成值和状态保留指引;工具层负责调用参数、SDK 声明和图片附加。工作流根据 `programFormat` 包装程序;所选运行时无法执行受限文件策略时,工作流拒绝执行。
Session 记录提交的程序、嵌套工具调用和返回输出。它不序列化 REPL 堆,也不在重启、恢复或派生后重新执行旧程序,因为重放自动化可能重复外部操作。缺失变量产生普通程序错误,模型指引要求重新创建变量并获取资源。提供方没有引入日志之外的模型输入,因此不需要环境标识或新的持久 Session 事件。
取消、截止时间到期、协议失败、输出超限、所有者销毁和进程退出都会丢弃环境。普通异常保留此前发生的修改。保留的函数使用当前执行的宿主绑定;较早执行中的异步任务不能获取后续执行的工具权限或向其输出。
## 考虑过的替代方案
将提供方拆成独立 npm 包并增加共享库,可以分别分发,但需要维护额外的包、依赖声明和共享实现的跨包导出。两种 Node 模式目前没有不同发布节奏或独立依赖的需求。第二个插件子路径允许独立加载,但要求部署配置通过替换插件入口来选择执行策略。配置字段直接表达这个选择,并保留已有入口。需要独立分发或出现明显不同的依赖时,可以重新评估拆包。
为公共辅助代码新增 Cordis 服务,会增加注册和注入依赖,却没有提供独立替换的运行能力。内部函数和类已经能够完成复用。共同的执行基类还会把新进程清理与按所有者保留进程的生命周期耦合起来,因此提供方共用处理机制,各自管理生命周期。
序列化任意 JavaScript 对象无法保留原生资源和闭包。重放旧程序可能重复写入和输入操作。这两种做法都会暗示内存保留模式没有提供的恢复保证。
Harness 进程内的独立执行上下文与模型代码共享进程生命周期,无法单独终止同步死循环。托管子进程可以在正常使用时保留导入和变量,同时支持取消与资源清理。仅有进程所有权不能提供文件沙箱或多租户隔离。
## 影响
选择 `persistent-in-memory` 会保留执行状态;[权限决策](2026-10-08-persistent-ptc-sandbox.zh.md)定义其文件限制。嵌套工具仍遵循原有授权和日志规则。即使 Harness 没有重启,内存和原生句柄也可能丢失。模型需要从引用或资源错误中恢复,运行时不会自动重试程序。
空闲所有者保留一个没有过期计时器的进程。调用方显式释放所有者,没有所有者的服务调用使用临时进程。程序使用 REPL 语句和末尾表达式,不支持顶层 return;同一保留所有者的调用不能重叠。工具 schema 和程序指引跟随所选模式。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-08-persistent-ptc-sandbox.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-08-persistent-ptc-sandbox.md
/agent-note-retained-ptc-processes-follow-file-policy:
en: ef07a82c85e490e1
zh: b1662d1970d2b83b
/agent-note-retained-ptc-processes-follow-file-policy/problem:
en: b8cb574032d4e6e4
zh: 6a1a25dec5f96086
/agent-note-retained-ptc-processes-follow-file-policy/decision:
en: 5109a4bb5be39867
zh: 2dcc602aba9eae61
/agent-note-retained-ptc-processes-follow-file-policy/alternatives-considered:
en: 10bebc7f982cf7f4
zh: 9247a2ba57921a11
/agent-note-retained-ptc-processes-follow-file-policy/consequences:
en: b3ddf273158d3754
zh: 916356f4074b3622
@@ -0,0 +1,31 @@
# Agent Note: Retained PTC processes follow file policy
Status: implemented
English | [中文](2026-10-08-persistent-ptc-sandbox.zh.md)
## Problem
Retaining JavaScript variables does not require broader file access. A process that outlives one execution can also outlive a one-time approval or a Session policy change. File-policy changes must invalidate retained environments and begin stopping their file handles and background work.
## Decision
`persistent-in-memory` starts its Node process through the same sandbox provider as `per-call`. A retained owner uses one resolved mode, workspace root and Session identity until release. The [memory decision](2026-09-23-best-effort-persistent-ptc.md) owns retained values, failure recovery and package composition; this note owns file authority.
The [PTC-owned cleanup decision](2026-10-09-ptc-owned-policy-cleanup.md) supersedes this note’s permission-setter completion and cleanup-ownership decision. Permission changes commit synchronously; PTC owns immediate cancellation and waits internally before subsequent execution. The tools package keeps Agent ownership and omits the owner for temporary escalations.
An approved one-time escalation omits the state owner and receives a fresh temporary environment. Its process is released before the call returns. It cannot access standing variables, and its declarations do not survive. The standing environment keeps its original policy and variables. Nested tools continue to apply their own permissions and approvals.
## Alternatives considered
**Unconfined retained execution.** Omitting confinement simplifies a temporary prototype but couples variable retention to full host file access. The existing process sandbox supports long-lived processes, so this grant is unnecessary.
**Changing the running process policy.** Existing backends apply restrictions at launch. Arbitrary retained objects, open handles and background work cannot be transferred into another policy with the existing interface. Releasing the environment prevents later evaluations from reusing those resources.
**Coordinating through permission presets.** File-policy changes also have direct callers. PTC observes committed Session mode events from both paths, independently of user-facing preset selection.
**Keeping an elevated environment.** A one-time approval cannot authorize future programs or idle background work. Omitting the state owner makes the approval lifetime explicit while preserving the standing environment.
## Consequences
Normal calls retain variables without changing file permissions. Changing sandbox mode discards variables. Escalated programs must be self-contained. Runtime consumers must release a retained owner before supplying a different policy without a Session mode change; the provider rejects inconsistent reuse. The sandbox inherits the existing backends’ file-effect coverage and platform limitations.
@@ -0,0 +1,31 @@
# Agent Note: 保留状态的 PTC 进程遵循文件策略
Status: implemented
[English](2026-10-08-persistent-ptc-sandbox.md) | 中文
## 问题
保留 JavaScript 变量不需要更大的文件访问权限。跨调用存活的进程也可能在单次审批结束或 Session 策略变化后继续运行。文件策略变化必须使保留环境失效,并开始停止其文件句柄和后台工作。
## 决策
`persistent-in-memory` 与 `per-call` 通过同一个沙箱提供方启动 Node 进程。保留状态的所有者在释放前使用固定的已解析权限模式、工作区根目录和 Session 身份。[内存决策](2026-09-23-best-effort-persistent-ptc.zh.md)负责保留值、失败恢复和包组成;本文负责文件权限。
[PTC 自行等待清理的决策](2026-10-09-ptc-owned-policy-cleanup.zh.md)替代本文关于权限设置完成时点和清理归属的决策。权限变化同步提交;PTC 负责立即取消,并在后续执行前内部等待清理。工具包保留 Agent 所有权,在临时提权时省略所有者。
单次获准的提权省略状态所有者,使用全新的临时环境。该次调用返回前释放进程,无法访问原环境的变量,也不会保留新声明。原环境保持原权限和变量。嵌套工具继续应用各自的权限和审批规则。
## 考虑过的替代方案
**不限制保留状态的执行环境。** 省略沙箱能简化临时原型,却把变量保留与完整宿主文件访问权限绑定。现有进程沙箱支持长期运行的进程,因此这项授权没有必要。
**修改运行中进程的策略。** 现有后端在启动时应用限制。现有接口无法把任意保留对象、打开的句柄和后台工作转移到另一策略。释放环境可以防止后续执行复用这些资源。
**通过权限预设协调清理。** 文件策略也有直接调用方。PTC 观察两类调用方提交的 Session 模式事件,无需依赖面向用户的预设选择。
**保留提权环境。** 单次审批不能授权未来程序或空闲期间的后台工作。省略状态所有者明确了审批生效的范围,同时保留原环境。
## 影响
普通调用保留变量,同时维持原文件权限。切换沙箱模式会丢弃变量。提权程序必须包含执行所需的完整代码。未发生 Session 模式变化时,运行时消费者在传入不同策略前必须释放保留状态的所有者;提供方拒绝策略不一致的复用。沙箱继承现有后端的文件约束范围与平台限制。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-09-message-node-factories-and-delegates.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-09-message-node-factories-and-delegates.md
/agent-note-domain-image-slots-and-a-shared-gallery-factory:
en: bce6358fb4211261
zh: bfebd3bbdb1affb1
/agent-note-domain-image-slots-and-a-shared-gallery-factory/problem:
en: ec6ffd7f737a1f56
zh: 5e368f87ce52e28c
/agent-note-domain-image-slots-and-a-shared-gallery-factory/decision:
en: b807777fd48560eb
zh: e7b1625bd4448ce1
/agent-note-domain-image-slots-and-a-shared-gallery-factory/alternatives-considered:
en: db14fd6d0e2ce78b
zh: c29a19cde692a1dd
/agent-note-domain-image-slots-and-a-shared-gallery-factory/verification:
en: d82e036edb981e7c
zh: 5a05e9a2e6489d21
/agent-note-domain-image-slots-and-a-shared-gallery-factory/consequences:
en: ac387ffb5a316920
zh: ab584aaafe554278
@@ -0,0 +1,54 @@
# Agent Note: Domain image Slots and a shared gallery Factory
Status: implemented
English | [中文](2026-10-09-message-node-factories-and-delegates.zh.md)
## Problem
User messages, assistant output, Trajectory records, and tool results need independently replaceable image presentation. Their official entries can share one gallery, but sharing that implementation must not merge their replacement positions or hide the caller's image-reading authority.
## Decision
Each domain declares its own image Slot and supplies the framework's `renderSlot` seat to the component that renders it. The official attachment entries use one reusable Content Factory, following the [Body Slot and Content Factory division](2026-10-09-reasoning-content-slot-factory.md).
| Entry | Declaration owner and renderer |
| --- | --- |
| `conversation.chat.user.images` Slot | The `conversation.chat.user.content` Factory declares it; `UserContent` renders it for user messages, pending steering, and local echoes. |
| `conversation.chat.message.images` Slot | The Assistant node registration declares it; `AssistantMarkdown` renders consecutive output-image groups. |
| `conversation.trajectory.images` Slot | The Trajectory view registration declares it; record attachments and tool-output blocks render it through the ledger's Slot seat. |
| `tool.call.images` Slot | The `read_image` tool-view registration declares it; `ToolRow` renders the result image. |
| `conversation.images.content` Factory | The attachment plugin registers this root-scoped gallery, implemented by `MessageImagesContent`. |
The attachment plugin fills all four image Slots with the same `MessageImages` React adapter at priority 100. A smaller priority replaces only the selected domain's entry; disposing that replacement restores the official entry. Each adapter invokes `conversation.images.content` with explicit image references, `loadImage`, and layout parameters. The shared Factory neither declares the domain Slots nor selects a domain from a scene parameter.
The image callers use their declared Slot directly; no image-entry Factory exists solely to forward one Slot call. User text remains a separate shared `conversation.user.text` Factory with its own keyed node Slot, as specified by the [user-text decision](2026-10-10-user-text-json-nodes.md).
`AssistantNodeView` passes its Slot seat and `loadImage` to `AssistantMarkdown`. User wrappers pass the loader through `UserStyleBubble` to `UserContent`. Trajectory passes its Slot seat and loader through the ledger. These paths preserve component responsibilities, image order, and keys without custom ReactNode-producing image callbacks.
Conversation owns read authorization, per-Session URL caching, and cleanup. The shared gallery never searches for a global current Session: persistent reads use the loader supplied by the caller, while submission-echo previews retain their existing browser URLs. Gallery layout, retries, thumbnails, and lightbox behavior remain in the attachment package.
`ChatView` keeps the existing `MarkdownDelegateProvider` with its wrapped file opener, external-link action, and file-image capabilities. Native-image composition does not replace Markdown image handling or its failure feedback.
`MessageImagesContentInput` accepts optional complete `MessageImageLabels`. Supplied labels are used unchanged; when omitted, the Content Factory's locale seat supplies the default copy.
## Alternatives considered
**One shared image Slot for every caller.** A replacement intended for one domain would also affect the others; shared implementation does not require shared replacement identity.
**Import the gallery component across feature packages.** This bypasses standard Slot replacement and the reusable Factory entry point.
**Pass custom image-rendering functions or supply the loader through a private Context.** Framework Slot seats already express rendering authority, while explicit loader props preserve the caller's Session authority.
## Verification
- User and output images use separate Slots; replacing either leaves the other and its mounted gallery untouched.
- Each official image entry has priority 100; a priority-0 replacement wins and disposal restores the official gallery.
- All four defaults call the same Content Factory with their original image data, authorized loader, and layout choices.
- Source order, keys, cached previews, failed-load retries, and lightbox interactions retain their existing behavior.
- The Content Factory accepts complete custom labels and resolves default labels when the caller omits them.
- Unloading the attachment implementation removes its entries without removing Slots declared by the owning domains.
## Consequences
Independent Slots allow domain-specific presentation without copying the gallery. Domain callers must forward the framework Slot seat to their plain React descendants and must supply an authorized loader at each image call. A Factory and an ordinary Slot have different lifetimes and ownership; a reusable implementation does not own every position where it appears.
@@ -0,0 +1,54 @@
# Agent Note: 领域图片 Slot 与共享画廊 Factory
Status: implemented
[English](2026-10-09-message-node-factories-and-delegates.md) | 中文
## 问题
用户消息、助手输出、Trajectory 记录和工具结果需要可独立替换的图片展示。官方条目可以共享同一画廊,但共享实现不能合并各自的替换位置,也不能隐藏调用方的图片读取授权。
## 决策
各领域声明自己的图片 Slot,并把框架 `renderSlot` 席位交给实际渲染它的组件。官方附件条目复用一个 Content Factory,沿用 [Body Slot 与 Content Factory 的分工](2026-10-09-reasoning-content-slot-factory.zh.md)。
| 入口 | 声明所有者与渲染组件 |
| --- | --- |
| `conversation.chat.user.images` Slot | `conversation.chat.user.content` Factory 声明,由 `UserContent` 为用户消息、待处理 steering 和本地回显渲染。 |
| `conversation.chat.message.images` Slot | Assistant 节点注册声明,由 `AssistantMarkdown` 渲染连续的输出图片组。 |
| `conversation.trajectory.images` Slot | Trajectory 视图注册声明,记录附件及工具输出块通过账本的 Slot 席位渲染。 |
| `tool.call.images` Slot | `read_image` 工具视图注册声明,由 `ToolRow` 渲染结果图片。 |
| `conversation.images.content` Factory | 附件插件注册此 root 作用域画廊,由 `MessageImagesContent` 实现。 |
附件插件以优先级 100 为四个图片 Slot 填入同一个 `MessageImages` React 适配组件。较小的优先级只替换选定领域的条目;替换条目释放后恢复官方条目。适配组件显式向 `conversation.images.content` 传入图片引用、`loadImage` 和布局参数。共享 Factory 不声明各领域的 Slot,也不通过场景参数选择领域。
图片调用方直接使用其声明的 Slot,不设置仅转发一次 Slot 调用的图片入口 Factory。用户文本仍使用独立共享的 `conversation.user.text` Factory 及其 keyed 节点 Slot,详见[用户文本决策](2026-10-10-user-text-json-nodes.zh.md)。
`AssistantNodeView` 将 Slot 席位与 `loadImage` 传给 `AssistantMarkdown`。用户包装层通过 `UserStyleBubble` 将 loader 传给 `UserContent`。Trajectory 通过账本传递自己的 Slot 席位与 loader。这些路径保持组件职责、图片顺序和 key,不使用自制的 ReactNode 图片渲染回调。
Conversation 拥有读取授权、逐 Session URL 缓存与回收。共享画廊不查找全局当前 Session:持久图片使用调用方提供的 loader 读取,本地提交回显预览保留已有浏览器 URL。画廊布局、重试、缩略图和灯箱行为仍由附件包负责。
`ChatView` 保留既有 `MarkdownDelegateProvider` 及其包装后的文件打开动作、外链动作和文件图片能力。原生图片组合不替代 Markdown 图片处理及失败反馈。
`MessageImagesContentInput` 接受可选的完整 `MessageImageLabels`。提供 labels 时原样使用,省略时由 Content Factory 的 locale 席位提供默认文案。
## 考虑过的替代方案
**所有调用方共用一个图片 Slot。** 面向某个领域的替换也会影响其他领域;共享实现不要求共享替换身份。
**跨功能包导入画廊组件。** 绕过标准 Slot 替换机制和可复用的 Factory 入口。
**传递自制图片渲染函数,或通过私有 Context 供应 loader。** 框架 Slot 席位已经表达渲染权限,显式 loader props 则保留调用方的 Session 授权。
## 验证
- 用户与输出图片使用独立 Slot;替换任意一方时,另一方及其已挂载画廊不受影响。
- 各官方图片条目优先级为 100;优先级 0 的替换条目胜出,释放后恢复官方画廊。
- 四个默认条目携带原有图片数据、授权 loader 和布局选项调用同一个 Content Factory。
- 来源顺序、key、缓存预览、读取失败重试及灯箱交互保持既有行为。
- Content Factory 接受完整自定义 labels,调用方省略时解析默认 labels。
- 卸载附件实现会移除其条目,但不移除各领域声明的 Slot。
## 影响
独立 Slot 支持各领域定制展示而无需复制画廊。领域调用方需要将框架 Slot 席位传给普通 React 后代组件,并在每次图片调用中提供授权 loader。Factory 与普通 Slot 的生命周期和所有权不同;可复用实现不拥有它出现的所有位置。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-09-ptc-owned-policy-cleanup.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-09-ptc-owned-policy-cleanup.md
/agent-note-ptc-owns-policy-change-cleanup-waits:
en: dfe28bee0ab79ff6
zh: dd68c51b4cbfbc3f
/agent-note-ptc-owns-policy-change-cleanup-waits/problem:
en: 5620ff525d762114
zh: ddef3a3624cab79b
/agent-note-ptc-owns-policy-change-cleanup-waits/decision:
en: 4389c330d95f1588
zh: 97e4b9cf8b69f48e
/agent-note-ptc-owns-policy-change-cleanup-waits/alternatives-considered:
en: a51d7f5c5b8fd7dc
zh: 4480107a4d8cff70
/agent-note-ptc-owns-policy-change-cleanup-waits/consequences:
en: f83009b6406bafa1
zh: c774eb1895bf37e1
@@ -0,0 +1,27 @@
# Agent Note: PTC owns policy-change cleanup waits
Status: implemented
English | [中文](2026-10-09-ptc-owned-policy-cleanup.zh.md)
## Problem
Stopping a retained Node process and its nested tools can take longer than recording a new file policy. Making permission setters await that cleanup spreads PTC lifecycle requirements into unrelated callers, including Webhook Session creation, and adds cancellation windows to those callers.
## Decision
Permission setters commit synchronously. Success means the selected policy is recorded; it does not guarantee that operations admitted under the old policy have completely stopped. This replaces the completion requirement and cleanup ownership in the [retained-process sandbox decision](2026-10-08-persistent-ptc-sandbox.md), whose confinement and one-time escalation rules still apply.
PTC observes committed Session mode changes. The tool bridge immediately cancels affected complete calls, including pending approvals and nested tools. A later PTC call waits for canceled predecessors to settle before resolving the latest standing policy. The Node provider immediately invalidates affected processes, including idle environments, starts cleanup, and retains the cleanup Promise internally. A later program waits for that cleanup; failed cleanup remains retryable and prevents process reuse. Unchanged modes preserve environments. Tool-registry and provider unload remove their observers and await owned work.
## Alternatives considered
**Wait in permission setters.** This provides a definite completion point for old-authority work, but no current permission consumer requires that guarantee. It introduces a general resource registry and asynchronous API migration for PTC-specific cleanup. The completion guarantee belongs to PTC execution and disposal instead.
**Clean up only on the next PTC call.** An idle environment can retain background work indefinitely when no further call occurs. Policy changes begin cancellation immediately; only waiting is deferred.
**Reconfigure an existing process.** The sandbox applies restrictions at launch. Open handles, retained objects and background work require releasing the old environment before another policy can execute there.
## Consequences
Permission changes return without process-cleanup latency. Old operations may remain active while cancellation finishes, and cleanup failure does not roll back the recorded policy. Subsequent PTC execution waits for cleanup and uses the latest policy. Webhook creation keeps its synchronous permission step, so that step adds no cancellation window before prompt admission. Regression coverage holds cleanup with explicit barriers, changes policy again while a call waits, checks idle cancellation without another call, and verifies cleanup retry and owner unload.
@@ -0,0 +1,27 @@
# Agent Note: PTC 自行等待策略变化后的清理
Status: implemented
[English](2026-10-09-ptc-owned-policy-cleanup.md) | 中文
## 问题
停止保留的 Node 进程及其嵌套工具,可能比记录新文件策略耗时更长。让权限设置等待这些清理,会把 PTC 生命周期要求传给 Webhook Session 创建等无关调用方,并为这些调用方增加取消窗口。
## 决策
权限设置同步提交。成功表示所选策略已经记录,不保证按旧策略接纳的操作已完全停止。这替代[保留进程沙箱决策](2026-10-08-persistent-ptc-sandbox.zh.md)中的完成要求和清理归属;其中的沙箱限制与单次提权规则继续适用。
PTC 观察已提交的 Session 模式变化。工具桥接立即取消受影响的完整调用,包括等待中的审批和嵌套工具。后续 PTC 调用先等待已取消的调用结束,再解析最新常设策略。Node 提供方立即使受影响的进程失效,包括空闲环境,开始清理并在内部保留清理 Promise。后续程序等待清理完成;清理失败可重试,且阻止复用进程。模式不变时保留环境。工具注册表和提供方卸载时移除各自监听器,并等待所拥有的工作结束。
## 考虑过的替代方案
**在权限设置中等待。** 这可以明确旧权限下的工作完成时点,但当前权限调用方不需要该保证。为此增加通用资源登记和异步 API 迁移,会扩大 PTC 清理的影响范围。完成保证由 PTC 执行和销毁负责。
**等下次 PTC 调用再清理。** 没有后续调用时,空闲环境的后台工作可能一直存在。策略变化立即开始取消,只有等待被推迟。
**重新配置已有进程。** 沙箱在启动时应用限制。打开的句柄、保留对象和后台工作要求先释放旧环境,才能在其中按另一策略执行。
## 影响
权限变化返回时无需等待进程清理。取消完成前,旧操作可能仍在运行;清理失败不会回滚已记录的策略。后续 PTC 执行等待清理并使用最新策略。Webhook 创建保留同步权限步骤,该步骤不会在提交任务前增加取消窗口。回归测试通过显式信号阻塞清理,覆盖等待期间再次改变策略、没有后续调用时取消空闲环境、清理重试及所有者卸载。
@@ -0,0 +1,40 @@
# Bilingual-pair consistency record for 2026-10-10-user-text-json-nodes.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-10-user-text-json-nodes.md
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh:
en: 6185ac67a459beba
zh: 994bf90f05c0fd5a
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/problem:
en: 42e3752efebedde5
zh: 75e23c5762efbb1f
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision:
en: 71f5b33f58cd2066
zh: 8365506795c0b158
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/rule-roles-and-original-coordinates:
en: ed0fa9306f0605cb
zh: 7240e135f6d1b46b
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/one-reconciliation-loop:
en: acaeb2dccfac3f07
zh: 2170ec2b858627fc
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/query-cache-and-traversal:
en: 55dee64de32bef9a
zh: 69eb625451f55268
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/actual-facts-dependencies:
en: 11bc718324d22c03
zh: 371576c1b2a90d62
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/facts-text-and-registration-refresh:
en: 47bc3f32b9aa22de
zh: 59ffd06c800c00af
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/output-ownership-and-publication:
en: e076cb57a1fdcfc1
zh: 988c02c6cf751f16
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/verification-requirements:
en: 78127c1f18eacf8a
zh: fcc2ad5db1279356
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/alternatives-considered:
en: 439e5ecff6701938
zh: 9861805f2a19439c
/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/consequences:
en: 13bd26532d0353fa
zh: d6a4997b51af09d5
@@ -0,0 +1,91 @@
# Agent Note: User-text candidate scheduling and Facts-driven refresh
Status: implemented
English | [中文](<2026-10-10-user-text-json-nodes.zh.md>)
## Problem
User text contains references owned by independent businesses. Recognition must follow original-text positions, resolve collisions deterministically, and reuse unaffected matching work when Facts change. The evaluator must expose its scheduling behavior to pure logic tests without depending on React, browser timing, or elapsed-time benchmarks.
## Decision
The composition is business registration, lazy candidate matching, a single reconciliation loop, readonly JSON nodes, and business-owned keyed renderers. Initialization and Facts refresh use the same algorithm; they differ only in which query caches remain valid.
### Rule roles and original coordinates
Every rule provides an ID, finite order, and synchronous `match(text, from, context)`. Each call returns its nearest eligible node with `start >= from`, or no candidate anywhere in the remaining original text. It returns one node, not all matches or a replacement document. The original string and UTF-16 offsets remain intact: a search cursor does not create a new word, line, or quoted-token boundary. Matching depends only on original text and Facts, not previously selected node payloads.
Each matcher resolves its own alternative spellings by earliest start and then its documented same-start choice; Session labels choose the longer match. A matcher cannot replace a consumed prefix with the remainder of an overlapping candidate. Each business owns its grammar, payload, and renderer; the generic evaluator has no Session, file, skill, or command branches.
There is one rule type and one candidate protocol. A candidate contains either a business node using the rule ID or a Text node preserving the original substring with an optional `data.display`. Text-producing rules compete by the same start, order, and stable ID as business rules. Order only breaks same-start ties; no rule gets precedence over an earlier position.
### One reconciliation loop
The evaluator keeps original text, the previous successful segmentation, registered definitions, per-rule Facts reads, and query results. Nonempty text initially has one Text covering `[0, text.length)`; empty text has no nodes. Initialization makes every rule invalid, including rules that read no Facts.
Each reconciliation starts its result cursor at zero. Every matcher obtains one candidate through the same cache-aware query operation. The evaluator selects the smallest candidate start, breaks same-start ties by ascending rule order and stable ID, emits the preceding raw Text gap, emits the winner, and advances the cursor to the winner's end. No candidate means the remaining raw text is emitted and recognition ends.
Only a consumed candidate or a candidate whose start is before the new cursor needs another query. A cached candidate beginning at or after the cursor remains eligible. A no-candidate result remains eligible as the cursor advances. All ranges are nonempty half-open original-text ranges; candidates cannot move the cursor backward or exceed the original string.
Previously accepted nodes are reusable output, not a complete list of possible matches: a losing candidate can become visible when an earlier winner disappears. The evaluator retains queries for this purpose instead of treating an old Text node or the absence of a business node as unconditional evidence of no match.
### Query cache and traversal
A query record contains its original `from` offset and either the next candidate, no candidate, or a failure. If a query from `q` returned a candidate starting at `s`, its result is valid for any requested cursor `c` with `q <= c <= s`. A no-candidate result is valid for every `c >= q`. Neither result proves anything before `q`. A failed query is reusable only at the same requested offset while its dependencies remain valid.
During a reconciliation, each matcher has one active candidate. The evaluator also retains the sorted query records already produced for that matcher across reconciliations; it does not enumerate all possible matches eagerly. Forward cursors walk these records monotonically. Queries generated in newly exposed gaps are merged with retained records in source order, with one record per query offset. Unsearched intervals require matching rather than an invented negative result.
The result cursor and matcher search cursors are distinct. Starting result reconciliation at zero does not require unchanged matchers to scan from zero again: their recorded queries answer valid requests. Each matcher must implement a forward search rather than repeatedly materializing every match in the remaining text. Internal scans, candidate selection, and result comparison still have costs; the scheduling guarantees concern actual matcher calls, not a claim that arbitrary regular expressions are linear.
### Actual Facts dependencies
`context.read(key)` returns the borrowed readonly value from the current Facts snapshot and records the read, including `undefined`. The reader is valid only during its owning synchronous call and cannot be retained. Businesses receive neither a whole Facts object, a manual `dependsOn` list, nor previous-output/cache objects.
A matcher's dependency set is the union of reads for the queries retained in its current cache generation, including losing candidates, no-match queries, and failed calls. Changed fields are detected with `Object.is` at field granularity. A new outer Facts object alone does not invalidate matching. When a rule is invalidated, its old queries and read set are discarded; subsequent calls establish the replacement read set, so obsolete conditional dependencies do not remain permanently attached.
Text-producing rules use the same query cache and dependency generation as every other rule. Updating a whitespace switch invalidates only its readers; it does not trigger an unconditional pass over the completed nodes.
### Facts, text, and registration refresh
A Facts update invalidates only rules that read changed fields. Their first query starts at original offset zero; all other rules retain their query records. The same reconciliation loop then compares fresh candidates with reusable ones. Refreshing a rule does not give it priority, and a new first candidate does not hide the removal of an earlier old winner.
A fresh candidate confined to previously plain text can split that Text without invoking unrelated matchers whose cached queries still cover the new cursors. A fresh candidate that loses to an earlier unchanged winner is discarded without invalidating that winner. When a fresh winner covers another candidate, only expired heads advance. When a winner disappears or shrinks, cached losing candidates can compete again; any previously unsearched interval causes a query at the newly exposed cursor. All changes use the same cursor loop and query-coverage rules.
Several changed Facts fields form one snapshot and one invalidation set. All affected matchers participate in the same reconciliation; one update is not committed before processing the next. Equivalent refreshed results reuse previous nodes and, when all elements remain identical, the entire result array. Reconciliation settles the complete document before publishing its result.
Original-text changes discard all query caches and supply one full-range Text as the previous segmentation. New or replaced rule definitions start with empty caches; unchanged definitions retain their independent queries. Removed rules lose their dependencies and candidates. Reconciliation after additions, removals, or order changes uses the same selection logic and can expose formerly blocked candidates.
### Output, ownership, and publication
Nodes retain `{ id, start, end, data }`. Business nodes use their matcher's ID; Text retains the exact source substring in `data.text` and optional display text in `data.display`. Output is ordered, nonoverlapping, and completely covers the original text. JSON compatibility and readonly ownership are TypeScript obligations: the evaluator neither freezes borrowed objects nor deep-copies Facts or payloads for isolation.
The document evaluator owns all caches; the registry retains neither evaluators nor document subscriptions. Separate text instances do not share state through a global cache. Only fully successful evaluation publishes a new result. A matcher failure carries the rule ID and cause and publishes no partial segmentation; its actual reads remain available for dependency-driven retry. Reentrant evaluation and escaped synchronous read contexts are rejected.
`conversation.user.text` remains a session-scoped Factory owning `conversation.user.text.node`, a keyed inline Slot. React consumes final nodes and forwards `raw` and `interactive` to the business renderer. Session, skill, file/folder, and command matching remain owned by their respective plugins, and actions remain in each renderer's Injection. No matching work or cache subscription moves into React.
`collapseWhitespace` and `trimWhitespace` each control one ordinary matcher and default to off. Collapse offers whitespace runs as Text candidates with display `" "`; trim offers original leading/trailing whitespace runs with display `""`. Trim has lower order than collapse, so it wins their same-start edge collisions. An earlier reference candidate consumes its whole range and naturally excludes internal whitespace candidates. Every Text candidate retains its exact raw substring; emoji and spaces between references remain intact. `interactive` and CSS `ellipsis` remain React Props, not matching Facts. Queue uses full original text with CSS ellipsis; native attachments remain in their existing cards and image Slots, outside text recognition.
## Verification requirements
Pure logic tests assert both the final nodes and each matcher's ordered calls with their `from` offsets. They use deterministic input and spies rather than clocks or performance thresholds.
- Initialization calls every matcher, including Facts-free and no-match rules; empty input has explicit terminal-query behavior.
- Earlier starts beat stronger order values; same-start ties use order and stable ID, and each matcher preserves its own longest-match choice.
- A future candidate and a no-match answer are not requested again during forward traversal; only the winner and overlapping heads advance.
- Unread Facts changes and new outer Facts objects with unchanged field values cause no matcher calls.
- One or several changed fields rerun exactly their readers; missing fields, losing candidates, no-match results, failures, and conditional read-set replacement remain covered.
- Equivalent refreshes preserve node/array identity and do not invoke unrelated matchers; payload-only changes preserve unaffected nodes.
- Insertions in plain text, losing new candidates, same-start winner changes, extending/shrinking matches, and removal of an early winner assert the exact affected query offsets and counts.
- Removing or shrinking a winner exposes cached losers and unsearched gaps; an old tail no-match result cannot answer a query before its recorded origin.
- Batched Facts updates equal a fresh evaluation under the same snapshot and do not give the first refreshed matcher precedence.
- Text and rule-definition changes, registration/disposal, range validation, synchronous failure containment, Context lifetime, and independent evaluator state have deterministic tests.
- Real Session/file/skill/command combinations preserve full quoted references, original word boundaries, and same-start precedence; whitespace candidates use exactly the same scheduling and cache rules.
## Alternatives considered
**Ordered whole-document business transforms.** A business can consume later text before an earlier reference from another business is considered, so fixed type order does not express leftmost recognition.
## Consequences
Field-level invalidation can require a matcher to traverse the document again; it does not promise per-element Facts invalidation. Query history consumes memory proportional to the distinct recorded search positions for the current text and definitions. Reconciliation and JSON identity alignment can still traverse output without invoking matchers. The performance evidence is the exact scheduling count and query offsets, while semantic evidence compares refreshed output with a fresh evaluator. This mechanism requires no browser end-to-end or elapsed-time benchmark tests.
@@ -0,0 +1,91 @@
# Agent Note: 用户文本候选调度与 Facts 驱动刷新
Status: implemented
[English](<2026-10-10-user-text-json-nodes.md>) | 中文
## 问题
用户文本包含独立业务拥有的引用。识别必须按原文位置推进,确定性地处理冲突,并在 Facts 变化时复用无关匹配工作。计算器必须能通过纯逻辑测试展示调度行为,不依赖 React、浏览器时序或耗时基准。
## 决策
采用业务注册、按需候选匹配、单一协调循环、只读 JSON 节点和业务拥有的 keyed renderer。初始化与 Facts 刷新使用同一个算法,只在查询缓存的有效性上存在差别。
### 规则职责与原始坐标
所有规则都提供 ID、有限的 order 和同步 `match(text, from, context)`。每次调用返回 `start >= from` 的最近有效节点,或确认剩余原文中没有候选。它返回一个节点,不返回全部匹配或整份替换文档。原始字符串及 UTF-16 坐标保持不变:搜索游标不创造新的词首、行首或带引号词元边界。匹配只依赖原文和 Facts,不依赖先前选中节点的 payload。
每个匹配器先按最早起点处理自身不同写法,再按该语法规定处理同起点选择;Session 标签选择较长匹配。匹配器不能把被消费前缀之后的半截重叠候选变成新匹配。语法、payload 和 renderer 均归对应业务所有,通用计算器不包含 Session、文件、skill 或命令分支。
只有一种规则类型和一个候选协议。候选既可以包含使用规则 ID 的业务节点,也可以包含保留原文子串、携带可选 `data.display` 的 Text 节点。产生 Text 的规则与业务规则按相同的起点、order 和稳定 ID 竞争。Order 仅裁定同起点冲突,任何规则都不优先于更早的位置。
### 单一协调循环
计算器保留原文、上一次成功分段、注册定义、逐规则 Facts 读取记录和查询结果。非空原文最初对应一个覆盖 `[0, text.length)` 的 Text,空原文没有节点。初始化使所有规则失效,包括不读取 Facts 的规则。
每次协调都从结果游标零开始,各匹配器通过同一个带缓存的查询操作获取一个候选。计算器选择起点最小的候选,同起点按规则 order 升序和稳定 ID 裁定,输出前面的原文 Text 空隙,输出赢家,再将游标推进到赢家末尾。全部没有候选时,输出剩余原文并结束识别。
只有被消费的候选或起点早于新游标的候选需要再次查询。缓存候选从新游标或其后开始时仍然有效;无候选结论在游标前进时保持有效。所有范围均是原文中的非空半开区间;候选不能使游标倒退或超出原文。
先前采纳的节点是可复用输出,不是所有可能匹配的完整清单;更早的赢家消失后,落败候选可能重新可见。计算器为此保留查询,而不能将旧 Text 节点或缺少某类业务节点无条件解释为没有匹配。
### 查询缓存与遍历
查询记录包含原始 `from` 偏移,以及下一个候选、无候选或失败。若从 `q` 查询得到起点为 `s` 的候选,则在请求游标 `c` 满足 `q <= c <= s` 时可以复用;无候选结论可用于所有 `c >= q`。两者都不能证明 `q` 之前的情况。失败查询只在请求偏移相同且依赖仍有效时复用。
一次协调过程中,每个匹配器只有一个活动候选。计算器同时保留该匹配器跨次协调已经产生的有序查询记录,不预先枚举所有可能匹配。前进游标单调遍历这些记录;新暴露空隙中的查询按原文顺序与旧记录合并,每个查询偏移只保留一条记录。未搜索区间必须执行匹配,不能虚构无匹配结论。
结果游标与匹配器搜索游标不同。结果协调从零开始,不代表无变化的匹配器需要从零重新扫描,它们的查询记录可以回答有效请求。各匹配器必须实现向前搜索,不能每次调用都重新收集剩余文本中的全部匹配。内部扫描、候选选择及结果比较仍有成本;调度保证针对实际匹配器调用,不宣称任意正则表达式都是线性的。
### 实际 Facts 依赖
`context.read(key)` 返回本次 Facts 快照中借用的只读值,并记录读取,包括 `undefined`。读取器仅在所属同步调用期间有效,不得保留。业务既不获得完整 Facts 对象,也不维护手工 `dependsOn` 列表或接收上一轮输出/缓存对象。
匹配器依赖集合是当前缓存代次所保留查询读取字段的并集,包括落败候选、无匹配查询和失败调用。字段变化按 `Object.is` 比较,外层 Facts 对象换引用不单独使匹配失效。规则失效时丢弃旧查询及读取集合,后续调用建立新的集合,因此已经不再读取的条件依赖不会永久残留。
产生 Text 的规则与其他规则共用查询缓存和依赖代次。空白开关更新只使它的读取者失效,不触发对已完成节点的无条件遍历。
### Facts、原文与注册刷新
Facts 更新只使实际读取变化字段的规则失效。它们的首次查询从原文零开始,其他规则保留查询记录;随后由同一个协调循环比较新候选和可复用候选。规则刷新不赋予额外优先权,新首个候选也不能掩盖更早旧赢家的消失。
新候选完全位于原有普通文字内时,可以拆分该 Text;无关匹配器的缓存仍覆盖新游标时,不调用它们。新候选输给更早的有效旧赢家时,丢弃新候选,不使旧赢家失效。新赢家覆盖其他候选时,只推进过期的候选头。赢家消失或缩短时,缓存的落败候选可以重新竞争;此前未搜索的区间在新暴露游标处触发查询。所有变化都使用同一个游标循环和查询覆盖规则。
多个 Facts 字段变化形成同一份快照和一个失效集合,所有受影响匹配器共同参与一次协调,不先提交某个更新再处理另一个。刷新结果等价时复用旧节点,所有元素相同则复用整个结果数组。协调在整份文档确定后发布结果。
原文变化会丢弃全部查询缓存,并以覆盖全文的 Text 作为上一版分段。新增或替换的规则定义从空缓存开始,无变化定义保留各自独立查询。规则移除时删除其依赖和候选。新增、移除或顺序变化后的协调仍使用相同选择逻辑,并可暴露此前被遮挡的候选。
### 输出、所有权与发布
节点保留 `{ id, start, end, data }`。业务节点使用所属匹配器 ID;Text 的 `data.text` 保留准确原文子串,`data.display` 可保存显示文字。输出有序、非重叠并完整覆盖原文。JSON 兼容性与只读所有权由 TypeScript 协议约束;计算器既不冻结借用对象,也不为隔离而深拷贝 Facts 或 payload。
文档计算器拥有全部缓存;注册表不持有计算器或文档订阅,不同文本实例不通过全局缓存共享状态。只有完整成功的求值才能发布新结果。匹配器失败时保留规则 ID 与 cause,不发布部分分段;实际读取仍参与依赖驱动的重试。禁止重入求值以及逃逸的同步读取 Context。
`conversation.user.text` 保持 session 作用域 Factory,拥有 keyed inline Slot `conversation.user.text.node`。React 消费最终节点,将 `raw` 和 `interactive` 交给业务 renderer。Session、skill、文件/目录和命令匹配仍归各插件所有,动作仍由各 renderer 的 Injection 提供,匹配与缓存订阅不进入 React。
`collapseWhitespace` 与 `trimWhitespace` 分别控制一个普通匹配器,缺省均关闭。Collapse 将空白串作为显示值为 `" "` 的 Text 候选;trim 将原文开头/末尾的空白串作为显示值为 `""` 的候选。Trim 的 order 小于 collapse,因此在同起点的边缘空白冲突中胜出。更早的引用候选消费自身完整范围,自然排除内部空白候选。每个 Text 候选保留准确的原文子串,emoji 及引用之间的空格保持不变。`interactive` 和 CSS `ellipsis` 仍为 React Props,不是匹配 Facts。Queue 使用完整原文和 CSS 省略,原生附件保留既有卡片及图片 Slot,不进入文本识别。
## 验证要求
纯逻辑测试同时断言最终节点,以及每个匹配器有序的调用和 `from` 偏移。使用确定性输入与 spy,不依赖时钟或性能阈值。
- 初始化调用所有匹配器,包括不读 Facts 和始终无匹配的规则;空输入具有明确的终止查询行为。
- 较早起点胜过更强 order;同起点按 order 和稳定 ID 裁定,各匹配器保留自身最长匹配选择。
- 向前遍历不重复查询未来候选及无匹配结论,只推进赢家和重叠候选头。
- 未读取 Facts 字段变化,或字段值不变而外层 Facts 对象换引用,不调用匹配器。
- 一个或多个字段变化只重跑实际读取者;覆盖缺失字段、落败候选、无匹配结果、失败和条件依赖集合替换。
- 等价刷新保留节点/数组引用且不调用无关匹配器;仅 payload 变化保留无关节点。
- 普通文字中插入、落败的新候选、同起点换赢家、匹配扩张/缩短、较早赢家移除,均断言准确的受影响查询偏移与次数。
- 赢家移除或缩短后暴露缓存落败者及未搜索空隙;旧尾部的无匹配结论不能回答其查询起点之前的问题。
- 批量 Facts 更新等于相同快照下的新建计算器结果,不赋予先刷新匹配器额外优先权。
- 原文及规则定义变化、注册/注销、范围校验、同步失败隔离、Context 生命周期和计算器状态独立均有确定性测试。
- 真实 Session/文件/skill/命令组合保留完整带引号引用、原始词边界和同起点优先关系;空白候选使用完全相同的调度和缓存规则。
## 备选方案
**按顺序处理整篇文档的业务变换。** 某业务可能在另一业务更早的引用获得考虑前消费后面的文本,因此固定类型顺序不能表达最左优先识别。
## 影响
字段级失效仍可能要求某个匹配器重新遍历文档,不承诺 Facts 字段内部逐元素失效。查询历史的内存量与当前原文、定义下记录的不同搜索位置成正比。协调及 JSON 引用对齐可能遍历输出而不调用匹配器。性能证据是准确的调度次数和查询偏移,语义证据是刷新结果与全新计算器的等价性。本机制不需要浏览器端到端测试或耗时基准测试。
@@ -0,0 +1,25 @@
# Bilingual-pair consistency record for 2026-10-11-chat-group-tree-exchanges.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-11-chat-group-tree-exchanges.md
/agent-note-chat-group-tree-positions-and-exchanges:
en: b26212eda152d6c6
zh: a801a75ee0c07911
/agent-note-chat-group-tree-positions-and-exchanges/problem:
en: 84858bc0e1e268ab
zh: 39ebb2979968d6cc
/agent-note-chat-group-tree-positions-and-exchanges/decision:
en: c8c4091863dec269
zh: 57a0f26683867350
/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice:
en: db8d77cd773ad646
zh: ab2f03b8fc58e952
/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered:
en: 635ddcb04fefe8e2
zh: 360c171ccb613ed5
/agent-note-chat-group-tree-positions-and-exchanges/consequences:
en: c8cfdc3009b19b12
zh: 36c541cd9692057d
/agent-note-chat-group-tree-positions-and-exchanges/testing:
en: 0d7f5281e66b0c39
zh: 5be221de46e9e0f7
@@ -0,0 +1,51 @@
# Agent Note: Chat group-tree positions and exchanges
Status: implemented
English | [中文](2026-10-11-chat-group-tree-exchanges.zh.md)
## Problem
On next message folding kept only the last Turn of the loaded timeline expanded. A Turn woken by a notice (a settled subagent, goal round, schedule, or background job) is not submitted by this Client, yet its arrival made the preceding Turn historical and folded it. No own-input driver computed the exact remaining room for that fold, so the scroll layer reserved generic bottom space and a reader following the tail saw blank space. In the recorded Session the notice woke the next Turn 13–15 ms after the previous Turn ended; the minimal reproduction left 596 px blank that had not recovered after five seconds.
## Decision
A notice is neither body text nor a user message: folding and bottom advancement ignore it. The bottom changes from the last Turn to the latest exchange.
- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'turn' | 'process'` and ordered outermost first; a Node at a position also stands outside every inner grouping. `isAtOrOutside(node, position)` is the single reader of that order. Only `exchange` is read today; `turn` and `process` are reserved for migrating the `TURN_PROCESS_INDEPENDENT_KINDS` and process-group `INDEPENDENT` kind lists later without changing what `exchange` means.
- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; notices and later steering carry no position.
- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange".
As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path.
## Why mark the opening rather than the notice
The host claims the waking input right after `turn/start`, assembles the system prompt, and only then logs `step/start` and `user/message`; the recorded Session spent 187 ms in that gap. Marking only the notice leaves the new Turn untagged during the gap, so the bottom still advances and the previous Turn folds and then reopens. Marking the human opening and letting unmarked Turns continue the exchange keeps the gap inert.
## Alternatives considered
**Tag the notice as a continuation.** The 187 ms gap above makes it fold and reopen, and a claim that mixes a notice with human input needs extra classification.
**Name opening kinds in a list (`user`, `steering`).** A kind cannot separate Step 1 steering from mid-Turn steering, so a human steering into a notice-woken Turn would wrongly fold the previous exchange.
**Publish Turn-level Location data.** Only one Context may publish a key for a Turn, while opening input comes from several message Contexts; aggregating per Turn would repeat the inbox claim classification in a new Definition and read every Turn on each update.
**Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position.
**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab.
**Migrate both kind lists now.** That reaches well beyond this defect; the type reserves the positions and the migration is separate work.
## Consequences
- Notice-woken Turns no longer cause a fold, reserved room, or blank space, and the grouping mechanism reads declared positions instead of naming kinds.
- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again.
- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange.
- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here.
## Testing
- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`: position order, segmentation, count fallback, and the empty window.
- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`: latest-exchange membership and notification scope.
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer.
- `apps/web/tests/notice-wake-tail.e2e.ts`: after a long Turn a notice wakes the next Turn; the long Turn stays expanded through the pre-Step gap and after the notice reply, and a following reader sees the notice and the reply.
@@ -0,0 +1,51 @@
# Agent Note: Chat 分组树位置与交互段
Status: implemented
[English](2026-10-11-chat-group-tree-exchanges.md) | 中文
## Problem
「下次发送时收起」原本只让已加载时间线的最后一个 Turn 保持展开。由通知唤醒的 Turn(子 agent 完成、Goal Round、定时任务、后台作业)不是本客户端提交的,它一出现,上一轮就被当作历史折叠。这次折叠没有自己输入的驱动来计算精确的剩余补白,滚动层只能按通用规则预留底部空间,正在跟尾的读者因此看到一片空白。录制的真实 Session 中,通知在上一轮结束后 13–15 ms 唤醒新轮次;最小复现里空白高 596 px,五秒后仍未恢复。
## Decision
通知不算正文,也不算用户消息:折叠和推进底部时都忽略它。底部从"最后一个 Turn"改为"最新交互段"。
- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时也站在所有内层分组之外。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口。目前只读取 `exchange`;`turn` 和 `process` 预留给以后把 `TURN_PROCESS_INDEPENDENT_KINDS` 与过程组 `INDEPENDENT` 两份 kind 名单迁移过来,届时不改变 `exchange` 的含义。
- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;通知和之后的 steering 不标。
- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。
结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。
## 为什么标开头而不是标通知
Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示,再写 `step/start` 和 `user/message`。录制的 Session 中这段间隔为 187 ms。只给通知打标的话,这段时间新 Turn 还没有标签,底部照样推进,上一轮会先折叠再展开。把标签放在真人开头上、未标的 Turn 默认续接,空白间隔里什么都不会发生。
## Alternatives considered
**给通知打"续接"标签。** 上面那 187 ms 的间隔会让它先折后展;同一次认领里通知和真人输入并存时还要额外判断。
**按 kind 写一张开启名单(`user`、`steering`)。** kind 区分不了 Step 1 的 steering 与 Turn 中途的 steering,真人往通知唤醒的 Turn 里插话时会错误地折叠前一个交互段。
**发布到 Turn 级 Location data。** 同一 Turn 的同一个 key 只允许一个 Context 发布,而开启输入来自多个消息 Context;改成按 Turn 汇总就得在新的 Definition 里重复 inbox 认领分类,并且每次都要读取所有 Turn。
**节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。
**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。
**这次就迁移两份 kind 名单。** 改动面明显超出这次缺陷;类型先预留位置,迁移另做。
## Consequences
- 通知唤醒的 Turn 不再产生折叠、补白和空白;分组机制读取节点声明的位置,而不是点名 kind。
- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。
- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。
- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。
## Testing
- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`:位置顺序、切分、计数回退与空窗口。
- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`:最新交互段的成员与通知范围。
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer。
- `apps/web/tests/notice-wake-tail.e2e.ts`:长 Turn 之后由通知唤醒,在 Step 1 前的间隔和通知回复之后,上一轮都保持展开,跟尾读者看到通知与回复。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-29-reused-blank-runs-requested-preset.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-29-reused-blank-runs-requested-preset.md
/agent-note-a-reused-blank-session-runs-the-requested-preset:
en: 06173bc82e1d9f59
zh: 637352fd92142310
/agent-note-a-reused-blank-session-runs-the-requested-preset/problem:
en: 7514025504dc2ddd
zh: 96249d3472005023
/agent-note-a-reused-blank-session-runs-the-requested-preset/decision:
en: e396c291f742a5f3
zh: c67be0fd305215fe
/agent-note-a-reused-blank-session-runs-the-requested-preset/alternatives-considered:
en: ae99345e8df800e4
zh: a4d0c257c4216337
/agent-note-a-reused-blank-session-runs-the-requested-preset/consequences:
en: bc7dda06670b4d96
zh: 24f381b8cb6ed3f8
/agent-note-a-reused-blank-session-runs-the-requested-preset/testing:
en: 1aefca569f5dce81
zh: 527c9aacf5a2f6b8
@@ -0,0 +1,37 @@
# Agent Note: A reused blank Session runs the requested preset
Status: implemented
English | [中文](2026-09-29-reused-blank-runs-requested-preset.zh.md)
## Problem
Web New Session, Workspace navigation, and startup restoration reused a blank Session by naming it in `session.create`. The Host resumed that Session under the preset its log recorded, so a new conversation inherited the preset the blank had been created or switched under. When that preset was no longer declared, such as a removed or renamed definition or a legacy `$DSH_HOME/.agent-presets/<id>/` directory that nothing reads any more, every New Session in that Workspace and every startup restoration of that draft failed with `agent-preset/not-found` although the default preset was valid (issue #2566). A blank created under an earlier default silently kept that default. The client retried only `session/writer-held`, so the stale blank stayed first in line.
## Decision
`session.create` returns a Session for a new conversation. A named `sessionId` is a reuse candidate that the Host verifies: it must not have started a turn, must belong to the requested directory, must be an unarchived member of the requested Workspace, and must not be written by another owner. For a persisted candidate the Host repeats the started check under the writer it acquires. Requests naming one identity run in arrival order, including their preset resolution, and the directory and ownership checks precede that resolution. The reused Session runs the requested `agentPreset`, or the configured default when the request names none; the Host does not resolve the recorded preset for reuse. When the target differs, a live Session switches through the preset registry's `select`, and a persisted Session mounts the target in its unpublished resume setup and appends `agent-preset/selected` there, so the log names the composition before the Agent is published. A candidate that has started, fails placement, or is written by another owner stays unchanged, and the Host creates a new Session; the response names the Session used. An explicit request for an undeclared preset still fails with `agent-preset/not-found`, and resuming a started Session still resolves its recorded identity.
The Web client names the first eligible blank when it connects a Workspace or prepares an explicit draft, and passes the preset a flow knows before it starts, such as the creator entry's `cordis`; a plain New Session creates a fresh Session instead. Startup restoration names the saved blank with the preset its log recorded, reloading the Session's projections first because listed values are cached hints that can lag the log after an unclean Host exit, so a draft keeps its selection; after `agent-preset/not-found` or `agent-preset/invalid` it asks again without a preset, and the draft continues under the default. When the Host declines a named blank, the client reloads that Session's projections, because stale cached list metadata can still mark a started conversation blank and hide it from the Session tree.
## Alternatives considered
**Fall back to the default only when the recorded preset is missing.** This removes the failure but keeps an inherited preset whenever it still resolves, so a New Session after a default change runs the old default.
**Retry `agent-preset/not-found` in the client.** The stale blank stays the first reuse candidate, so later New Sessions fail once before creating another Session, and startup restoration and other callers still fail.
**Skip blanks whose preset is missing from the roster.** Navigation would need the roster, stale Sessions would never be repaired, and other callers would still fail.
**Let the Host choose the blank.** The Host would scan the catalog on every request, while the client already holds the ordered catalog; verification under the writer covers a stale client view.
**Keep a restored draft's preset on the Host when it resolves.** A request that names a blank without a preset would then mean "keep" for restoration and "default" for New Session. Passing the recorded preset gives each request one meaning.
## Consequences
A reused blank runs the default or the preset its flow names, including after the default changes or a definition disappears. A preset picked on a draft survives a page reload but not a later Workspace connection or draft preparation that reuses the draft, and reusing a draft that another tab shows changes that tab's preset too. Writer contention no longer reaches the client: the Host creates the replacement Session. A blank whose first prompt was accepted but whose turn has not started still counts as blank, as it does for `select`. A declined stale blank is corrected only in the client that named it, until the Session is next activated. The preset registry's `select` leaves a blank Session that records the requested preset and runs its current revision unchanged, so a client that also selects that preset after the creation request adds no second record.
This note replaces the client-side contention fallback of [Writer acquisition for blank Session reuse](../architecture/2026-09-17-process-local-blank-sessions.md); writer acquisition and candidate order remain as decided there.
## Testing
Session Controller tests cover live and persisted reuse, a recorded preset that is no longer declared, named presets, started and overtaken Sessions, writer contention, Workspace placement, storage failures, arrival order when an earlier preset resolves later, and directory checks before preset resolution. Workspace navigation tests cover restoration with and without a usable recorded preset, restoration when the listed preset lags the log, connection coalescing that a different preset request supersedes, and the projection reload for a declined blank; the preset plugin tests cover the creator entry, and the registry tests cover a repeated selection with and without a replaced definition. The shipped Web composition test reuses a blank recorded under an undeclared preset under the default when startup connects its Workspace and when startup restores it, keeps a restored draft's declared preset, and checks the durable `agent-preset/selected` record; the stale blank cache scenario checks that startup opens a new Session and still lists the declined conversation.
@@ -0,0 +1,37 @@
# Agent Note: 被复用的空白会话运行请求的 preset
Status: implemented
[English](2026-09-29-reused-blank-runs-requested-preset.md) | 中文
## Problem
Web 的新建会话、工作区导航与启动恢复过去通过在 `session.create` 中指明空白会话来复用它。Host 过去按日志记录的 preset 恢复该会话,因此新对话继承了空白会话创建时或切换后的 preset。当该 preset 已不再声明时,例如定义被删除或改名,或是如今已无人读取的旧版 `$DSH_HOME/.agent-presets/<id>/` 目录,即使默认 preset 有效,该工作区中的每次新建会话以及该草稿的每次启动恢复都会以 `agent-preset/not-found` 失败(issue #2566)。在更早默认值下创建的空白会话则会静默保留那个旧默认值。客户端只对 `session/writer-held` 重试,因此过期的空白会话始终排在复用候选首位。
## Decision
`session.create` 返回一个用于新对话的会话。请求指明的 `sessionId` 是由 Host 核实的复用候选:它必须尚未开始任何轮次、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用。对于持久化候选,Host 会在取得写锁后再次检查它是否已开始。指明同一标识的请求按到达顺序执行,preset 解析也包含在内,且目录与归属检查先于该解析。被复用的会话运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset;Host 复用时不解析记录的 preset。目标与当前不同时,活动会话通过 preset 注册表的 `select` 切换;持久化会话在尚未发布的恢复 setup 中挂载目标 preset,并在同一处追加 `agent-preset/selected`,使日志在 Agent 发布前就写明其组成。已开始、不满足归属或被其他写者占用的候选保持不变,Host 改为新建会话;响应指明实际使用的会话。显式请求未声明的 preset 仍以 `agent-preset/not-found` 失败,恢复已开始的会话仍解析其记录的标识。
Web 客户端在连接工作区或显式准备草稿时指明第一个符合条件的空白会话,并传入流程在开始前已确定的 preset,例如创造模式入口的 `cordis`;普通新建会话则直接新建 Session。启动恢复指明已保存的空白会话及其日志记录的 preset,并先重新加载该会话的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于日志,从而使草稿保留其选择;遇到 `agent-preset/not-found` 或 `agent-preset/invalid` 后改为不带 preset 再请求一次,草稿在默认 preset 下继续。Host 拒绝复用某个指明的空白会话时,客户端会重新读取该会话的投影,因为过期的列表缓存元数据可能仍把已开始的对话标为空白,并把它从会话树中隐藏。
## Alternatives considered
**只在记录的 preset 缺失时回退到默认值。** 这能消除失败,但只要继承的 preset 仍可解析就会保留它,因此默认值变更后的新建会话仍运行旧默认值。
**在客户端重试 `agent-preset/not-found`。** 过期的空白会话仍是第一个复用候选,之后每次新建会话都会先失败一次再新建,启动恢复和其他调用方仍会失败。
**跳过 preset 不在列表中的空白会话。** 导航需要读取 preset 列表,过期会话永远得不到修复,其他调用方仍会失败。
**由 Host 选择空白会话。** Host 每次请求都要扫描会话列表,而客户端已持有排好序的列表;在写锁下的核实足以覆盖客户端视图过期的情况。
**恢复草稿时由 Host 在记录的 preset 可解析时保留它。** 这样一个指明空白会话但未带 preset 的请求,在恢复时表示「保留」,在新建会话时表示「默认」。传入记录的 preset 让每个请求只有一种含义。
## Consequences
被复用的空白会话运行默认 preset 或其流程指明的 preset,默认值变更或定义消失之后也是如此。在草稿上选择的 preset 在刷新页面后仍保留,但之后复用该草稿的工作区连接或草稿准备不会保留它;复用另一个标签页正在显示的草稿也会改变那个标签页的 preset。写锁争用不再到达客户端:由 Host 新建替代会话。首条提示词已被接受但轮次尚未开始的空白会话仍算空白,与 `select` 一致。被拒绝复用的过期空白会话只在指明它的客户端中得到纠正,直到该会话下次被激活。若空白会话已记录所请求的 preset 且正运行其当前版本,preset 注册表的 `select` 不做改动,因此客户端在创建请求之后再选择同一 preset 也不会追加第二条记录。
本注记取代 [复用空白 Session 时获取写锁](../architecture/2026-09-17-process-local-blank-sessions.zh.md) 中的客户端锁争用回退;写锁获取与候选顺序仍按该决策执行。
## Testing
Session Controller 测试覆盖活动会话与持久化会话的复用、记录的 preset 已不再声明、指明的 preset、已开始与被抢先开始的会话、写锁争用、工作区归属、存储失败、较早请求的 preset 解析较慢时仍按到达顺序执行,以及目录检查先于 preset 解析。工作区导航测试覆盖记录的 preset 可用与不可用时的恢复、列表中的 preset 落后于日志时的恢复、被不同 preset 请求取代的连接不再合并,以及被拒绝的空白会话的投影重读;preset 插件测试覆盖创造模式入口,注册表测试覆盖定义未替换与已替换时的重复选择。实际 Web 组合测试在启动连接工作区与启动恢复时,把记录了未声明 preset 的空白会话复用到默认 preset 下,验证恢复的草稿保留已声明的 preset,并检查持久化的 `agent-preset/selected` 记录;过期空白缓存场景验证启动时打开新会话,并仍列出被拒绝复用的对话。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-10-background-shell-command-display.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-10-background-shell-command-display.md
/agent-note-separate-background-shell-command-display-from-job-state:
en: c531a4b2eb444a12
zh: 7c216f829d2e5168
/agent-note-separate-background-shell-command-display-from-job-state/problem:
en: 116454bc385c0cc3
zh: 241905c37c22cb45
/agent-note-separate-background-shell-command-display-from-job-state/decision:
en: ca05afff8b542979
zh: ccb19c8709718ca0
/agent-note-separate-background-shell-command-display-from-job-state/alternatives-considered:
en: 573a8d2e99de14e6
zh: 1e7767c951672b42
/agent-note-separate-background-shell-command-display-from-job-state/consequences:
en: 15b6456f87cfeaa2
zh: 5fec862a0180dc55
@@ -0,0 +1,33 @@
# Agent Note: Separate background Shell command display from job state
Status: implemented
English | [中文](2026-10-10-background-shell-command-display.zh.md)
## Problem
An explicit background Shell call already carries its complete command and working directory at Start, but the Tool card shows generic argument JSON rather than the ordinary Shell command area. The original call returns a launch acknowledgement; the background result arrives through a separate completion notice or `job_output` call. Those messages do not directly carry the original Bash callId, so the acknowledgement cannot establish execution completion, and the original call's state cannot represent the job's live state.
## Decision
Valid explicit background `bash`/`pwsh` calls reuse the existing terminal command presentation. Before Start returns, the card indicates only that the launch call is in progress. After a normal launch acknowledgement, the same Tool card retains its command, working directory, wrapping, and disclosure state, and becomes a static command-only display without a run-state dot, exit code, empty-output placeholder, or continuing running animation. Launch failures retain the existing raw-argument and error-result presentation; Inspect still exposes the complete arguments and launch acknowledgement.
The terminal component receives the original call's `background` flag from Start and combines it with the call's `running` value: `background=true` and `running=true` mean that the launch call is still in progress. When the acknowledgement arrives, `background` stays unchanged and `running=false` leaves a static command; it does not describe the background job's running state. This introduces no derived presentation parameter or job state, and does not keep `running=true` indefinitely to pretend that the command is still executing. Ordinary foreground calls, persistent Shell calls, and `terminal_send` retain their default behavior; the existing Copy button's output-copy semantics do not change.
This decision covers explicit background launches only, not timeout promotion. It adds no job subscription, polling, output assembly, or callId/jobId index. Later completion notices and `job_output` keep their independent presentation, while the header's job panel owns live job information. The Tool card does not receive the final output, and Host interfaces, execution, and the Session log remain unchanged.
The [Client tool-presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md#terminal-card) still governs raw-event derivation and business ownership. This note supersedes only its generic-body rule for explicit background Shell calls, not background `terminal_send`.
## Alternatives considered
**Parse the launch acknowledgement as an ordinary completed-command result.** The acknowledgement contains no command exit result and can therefore produce a false success indicator.
**Retain Start's running state indefinitely.** The original Tool call has returned, and without a job subscription the card cannot establish that the background job is still running.
**Match subsequent job output back to the original Tool.** This requires a reliable jobId-to-callId association and rules for incremental reads, missing history, and duplicate presentation. None is a prerequisite for displaying the existing command, so they remain outside this decision.
**Keep generic argument JSON as the only presentation.** The data remains available, but the card does not reuse the familiar Shell command presentation and retains an unnecessary difference between foreground and background calls.
## Consequences
Users can inspect the launch command in its original Tool card but cannot infer whether the background job is currently running, successful, or failed. Final state and output remain in the job panel, completion notices, and independent read calls. Component and assembly regressions cover disclosure preservation from Start to acknowledgement, absence of false state and empty output, launch failures, root/child calls, and unchanged foreground and `terminal_send` behavior; assembly-regression event cases use explicitly identified constructed data.
@@ -0,0 +1,33 @@
# Agent Note: 主动后台 Shell 的命令展示与任务状态分离
Status: implemented
[English](2026-10-10-background-shell-command-display.md) | 中文
## Problem
主动后台 Shell 在 Start 时已经具有完整 command 和工作目录,但 Tool 卡片使用通用参数 JSON,而不是普通 Shell 的命令区域。原调用返回的是启动回执,后台最终结果通过另一条完成通知或 `job_output` 调用出现;这些消息没有直接携带原 Bash callId,不能把启动回执当成执行完成,也不能用原调用状态代表任务实时状态。
## Decision
合法的主动后台 `bash`/`pwsh` 复用现有终端命令展示。Start 尚未返回时只表达启动调用正在进行;正常启动回执到达后,同一 Tool 卡片保留 command、工作目录、软换行及展开状态,切换为静态的仅命令展示,不显示执行状态点、退出码、空输出提示或持续运行动画。启动错误继续使用现有原始参数和错误结果展示;完整参数与启动回执仍可通过 Inspect 查看。
终端组件从 Start 起接收原调用的 `background` 标志,并结合调用的 `running` 决定展示:`background=true`、`running=true` 表示启动调用尚在进行;回执到达后 `background` 不变、`running=false`,只保留静态命令,不代表后台任务的运行状态。不新增派生展示参数或任务状态,也不以永久 `running=true` 伪造命令仍在执行。普通前台、持久 Shell 和 `terminal_send` 的默认行为不变;不改变原有 Copy 按钮的输出复制语义。
本次只处理主动后台启动,不修改超时转后台。没有 job 订阅、轮询、结果拼接或 callId/jobId 索引;后续完成通知和 `job_output` 保持原来的独立展示,标题栏任务面板继续拥有实时任务信息。Tool 卡片不回填最终输出,不修改 Host 接口、执行器或 Session 日志。
[Client 工具展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md#terminal-card)的原始事件派生与业务归属保持有效;本文仅替代其主动后台 Shell 使用通用正文的条目,不改变后台 `terminal_send`。
## Alternatives considered
**直接按普通已结束命令解析启动回执。** 回执没有命令退出结果,容易显示虚假的成功状态。
**永久保留 Start 的 running 状态。** 原 Tool 调用已返回,且不订阅任务状态,无法保证后台任务仍在运行。
**将后续 job 输出匹配回原 Tool。** 需要可靠的 jobId 与原 callId 关联,还要定义分次读取、缺失历史和重复展示的处理;这些不是展示已有 command 的前提,本次不做。
**继续只展示通用参数 JSON。** 数据虽然存在,但没有复用用户熟悉的 Shell 命令展示,保留了后台与前台的不必要差异。
## Consequences
用户能在原 Tool 卡片查看启动命令,但不能据此判断后台任务当前成功、失败或运行中。最终状态和输出仍在任务面板、完成通知及独立读取调用中查看。组件与装配回归覆盖 Start→回执的展开保持、无虚假状态与空输出、启动失败、root/child、普通前台及 `terminal_send` 不变;装配回归的事件用例使用明确标识的构造数据。
@@ -0,0 +1,31 @@
# Bilingual-pair consistency record for 2026-10-11-process-specific-prompt-values-in-runtime-context.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-11-process-specific-prompt-values-in-runtime-context.md
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context:
en: 711ce3e3afb1f905
zh: 2d4e4aea44043c4a
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/problem:
en: f4e050c7dbf645da
zh: 722bcdc409d09bec
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision:
en: 5222f00fd05d3801
zh: fcfa430a0fa5588c
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/ownership:
en: c23d5c27f1f160f0
zh: 5c2464666f1e6c29
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/new-sessions:
en: 1ee144c39905bb89
zh: a6669d7ad394e41c
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/earlier-format-sessions:
en: 27c11e7ced286898
zh: 944d051494b1498d
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/alternatives-considered:
en: c324412be4034c40
zh: 923ad612fc884be1
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/consequences:
en: 8aa41ff08b848916
zh: bc1c1647ca0eb86a
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/testing:
en: 9667d1749bdf1547
zh: 598dc39120c91228
@@ -0,0 +1,95 @@
# Agent Note: Process-specific checkout path and Web URL move to runtime context
Status: implemented
English | [中文](2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md)
## Problem
`dsh web` wrote two process-specific values directly into the system prompt: the implementation checkout path in the `harness:source` section (four directories above the web-app module) and the Web GUI URL in the `app:web-surface` section (the bound port or `publicUrl`). These operations change them without the user perceiving a system-prompt change:
| Operation | Changed value |
|---|---|
| Every Desktop launch (`--port 0` picks a random port) | Web URL |
| A different `--port`, or alternating A/B checkouts | Web URL, checkout path |
| npm and Desktop installations sharing one `DSH_HOME` | Checkout path |
On resume the loop renders the prompt again. On a route without `systemPromptUpdate: 'in-history'`, such as a hand-declared pi-ai `anthropic-messages` route, the changed prompt rewrites the surface head node. Claude binds historical thinking-block signatures to the conversation prefix, system prompt included, so the upstream answers 400 (`Invalid signature … bound to a different conversation`) and the Session cannot continue until the environment is restored. In-history routes do not fail, but every change appends a complete prompt.
## Decision
Both values become runtime context and the system prompt keeps only value-free guidance; a Session whose log already holds the earlier format keeps its logged paragraphs.
### Ownership
| Package | Responsibility |
|---|---|
| `@deepseek-ai/dsh-harness-dev-context` (`packages/context/harness-dev-context`) | Both sections, both contexts, and the earlier-format projection; all module-private, exporting only the plugin protocol (`name`, `inject`, `apply`) |
| `@deepseek-ai/dsh-host-webserver` | `WebServer.getOrigin()` (the bind address's URL form with protocol and bound port) and `bindUrlHost(host)` |
| `@deepseek-ai/dsh-web-app` | The `web-runtime` row provides `webApp`, whose `getAdvertisedUrl()` returns the configured `publicUrl`, otherwise the listener origin, the same value as `DSH_WEB_URL` and the startup URL; the bundle patch mounts the `harness-dev-context` row |
| `@deepseek-ai/dsh-system-prompt` | `CONTEXT_ORDERS.HARNESS_SOURCE = 200` and `WEB_SURFACE = 210` |
The plugin injects `systemPrompt`, `sessionProjections`, and `webApp` and reads those services in code; its patch row carries no configuration expression. The checkout path is four directories above this package's source or built entry, the same depth as the former web-app location, so source, npm, and Desktop installations resolve the same root. The URL is `webApp.getAdvertisedUrl()`, so a profile that overrides the `web-runtime` row's `publicUrl` reaches the model as well. The plugin reaches the `webApp` type through `import type {} from '@deepseek-ai/dsh-web-app'`; the import is erased at runtime, so the web-app bundle's dependency on this package remains the only runtime edge.
### New Sessions
| Registration | Content |
|---|---|
| Section `harness:source` (the `HARNESS_SOURCE` slot) | `The runtime context names the DeepSeek Harness implementation checkout. …`, the remaining guidance unchanged |
| Context `harness:source` (`CONTEXT_ORDERS.HARNESS_SOURCE = 200`) | `Current DeepSeek Harness implementation checkout: "<path>".` |
| Section `app:web-surface` (the `WEB_SURFACE` slot) | `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. …`, the remaining guidance unchanged |
| Context `app:web-surface` (`CONTEXT_ORDERS.WEB_SURFACE = 210`) | `Current DeepSeek Harness Web GUI URL: "<url>".` |
Both contexts are optional (no `required`). The only complete persona, web-app's minimal preset, also disables optional runtime context, so it still sees neither value. Runtime context follows the existing mechanism: an unchanged value emits no snapshot, a change appends a complete snapshot before the step's user message, and resume compares against the latest logged snapshot, never rewriting a committed message.
### Earlier-format Sessions
The plugin registers the host-only session projection `harnessDevLegacyPrompt`, folding every nonempty `system/message` in event order into `{ source, web }`: the paragraphs of that prompt that match an earlier template verbatim, or `null`.
| Earlier template | Matching rule |
|---|---|
| `harness:source` | Starts with `The DeepSeek Harness implementation checkout is at `, ends with the earlier fixed guidance, no line break between |
| `app:web-surface` | Starts with `You are interacting with the user through the DeepSeek Harness Web GUI at `, ends with `. ` plus the guidance as earlier releases rendered it, frozen as a literal, no line break between |
Both section text providers read the projection state of `AssembleContext.agent.session`: with an earlier paragraph they return it verbatim (`interpolate: false`), otherwise the current guidance. Diagnostic assemblies without an agent use the current guidance. `sessionProjections` is a required injection and the plugin registers its own unit, so the state is readable whenever an agent exists.
An earlier-format Session therefore satisfies both requirements:
| Scenario | System prompt | Runtime context |
|---|---|---|
| First resume after upgrading, same port | Byte-identical to the log; the loop emits no `system/message` | Appends a snapshot with both current values |
| Port, slot, or installation change after upgrading | Still the logged earlier paragraphs | Appends a snapshot with the new values |
The projection computes only on each committed `system/message`, adding no synchronous read of historical events; its state persists with projection checkpoints. When compaction or a new request series rewrites the head, the rendering still contains the same earlier paragraphs, so the projection state does not change.
## Alternatives considered
**Move both values to runtime context without keeping earlier paragraphs.** The upgrade itself changes the prompt text, so every existing Claude Session rewrites its head and fails with 400 on its first turn after the upgrade.
**Offer `supportsMidConvoSystemMessages` on hand-declared Anthropic routes.** The upstream accepts mid-conversation system messages in a live probe, but the pi-ai catalog already supplies that capability for named Anthropic routes and hand-declared routes withhold catalog fields by convention; in-history routes would still append a complete prompt on every value change.
**Ask the upstream to drop mismatched thinking blocks (`prefix_mismatch_behavior: "drop_block"`).** It silently loses historical reasoning and covers one vendor.
**Freeze the whole system prompt per Session.** Legitimate prompt changes such as plan mode, skills, and tool guidance would also be frozen and never reach the model.
**Keep the implementation in web-app and app-boot.** Development-facing context and earlier-format compatibility would enlarge the public API of two core packages; app-boot's `addHarnessSourceSection` had web-app as its only caller.
**Read `webStartup.publicUrl`.** It carries only the `--public-url` flag, so a profile override of the `web-runtime` row's `publicUrl` would reach `DSH_WEB_URL` but not the model; the effective value exists only inside web-app, which now provides it as `webApp`.
**Read the head node text from Session history directly.** The synchronous arbitrary-history read APIs are deprecated and new calls are prohibited; a projection is the established way to obtain that state after resume.
## Consequences
- A new Session's system prompt no longer changes with the port, slot, or installation; both current values arrive as runtime-context snapshots, and a change appends a few lines.
- An earlier-format Session keeps the old values of its earlier paragraphs for its whole lifetime; the later `Current …` runtime context in the same request gives the current values.
- Only these two paragraphs are protected. When other prompt text changes between releases, routes without in-history updates still rewrite the head.
- A release without this fix rewrites the head of a new-format Session with the earlier template on resume; both A/B checkouts need this fix before they alternate.
- app-boot no longer exports `addHarnessSourceSection` or `HARNESS_SOURCE_SECTION`; web-app's `surfaceContext` controls only `DSH_WEB_URL`, and disabling the `harness-dev-context` row turns both contexts off.
- Runtime context in recorded Web e2e Sessions gains two lines; the test scaffold normalizes the URL and checkout path to placeholders.
## Testing
- `packages/context/harness-dev-context/tests/harness-dev-context.spec.ts`: on a production AgentLoop, an earlier-format Session resumed on another port emits no `system/message`, the request's system prompt is byte-identical to the log, and runtime context carries the current URL; a second port change keeps this; replace-only and in-history routes each run. When a series consolidation only empties a later version, the next turn keeps the earlier paragraphs. Similar text without the fixed ending or broken across lines receives the current guidance. A new Session keeps its system prompt across a port change and only appends a runtime-context snapshot. Sections carry no values, the context carries the advertised URL, and disposing the plugin fiber removes every registration.
- `packages/host/webserver/tests/webserver.spec.ts`: `bindUrlHost` across address forms; `getOrigin()` on HTTP and HTTPS listeners, and its errors before binding and for a zoned non-loopback address.
- `packages/bundle/web-app/tests/web-app.spec.ts`: `webApp.getAdvertisedUrl()` returns the listener origin, or the row's configured `publicUrl`.
- `packages/core/system-prompt/tests/system-prompt.spec.ts`: the added context positions are unique integers.
@@ -0,0 +1,95 @@
# Agent Note: 进程相关的 checkout 路径与 Web URL 改走运行时上下文
Status: implemented
[English](2026-10-11-process-specific-prompt-values-in-runtime-context.md) | 中文
## 问题
`dsh web` 曾把两项随进程变化的值直接写进 system prompt:`harness:source` 段里的实现 checkout 路径(web-app 模块位置上溯四层),以及 `app:web-surface` 段里的 Web GUI URL(实际监听端口或 `publicUrl`)。下列操作都会改变它们,而用户感知不到 system prompt 变了:
| 操作 | 变化的值 |
|---|---|
| Desktop 每次启动(`--port 0` 取随机端口) | Web URL |
| 换 `--port`、A/B 两个 checkout 交替运行 | Web URL、checkout 路径 |
| npm 安装与 Desktop 安装共用同一个 `DSH_HOME` | checkout 路径 |
会话恢复时 loop 重新渲染 prompt。对不声明 `systemPromptUpdate: 'in-history'` 的路由(例如手写的 pi-ai `anthropic-messages` 路由),变化的 prompt 会改写 surface 头节点。Claude 的历史 thinking 块签名绑定包括 system prompt 在内的对话前缀,前缀一变上游返回 400(`Invalid signature … bound to a different conversation`),会话在环境恢复原样之前无法继续。声明 in-history 的路由不报错,但每次变化都追加一份完整 prompt。
## 决定
两项值改为运行时上下文,system prompt 只保留不含值的说明;已经按旧格式写入日志的会话继续沿用日志里的旧段落。
### 归属
| 包 | 职责 |
|---|---|
| `@deepseek-ai/dsh-harness-dev-context`(`packages/context/harness-dev-context`) | 两个 section、两个 context、旧格式投影;全部模块私有,只导出插件协议(`name`、`inject`、`apply`) |
| `@deepseek-ai/dsh-host-webserver` | `WebServer.getOrigin()`(绑定地址的 URL 形式加协议与实际端口)与 `bindUrlHost(host)` |
| `@deepseek-ai/dsh-web-app` | `web-runtime` 行提供 `webApp`,其 `getAdvertisedUrl()` 返回配置的 `publicUrl`,否则返回监听器 origin,与 `DSH_WEB_URL` 和启动 URL 同值;bundle patch 挂载 `harness-dev-context` 行 |
| `@deepseek-ai/dsh-system-prompt` | `CONTEXT_ORDERS.HARNESS_SOURCE = 200`、`WEB_SURFACE = 210` |
插件 `inject` 为 `systemPrompt`、`sessionProjections`、`webApp`,在代码里直接读取这些服务;patch 行不写任何配置表达式。checkout 路径取本包源码或构建入口上溯四层,与原 web-app 位置同深度,源码、npm 与 Desktop 安装下结果不变。URL 取 `webApp.getAdvertisedUrl()`,因此 profile 覆盖 `web-runtime` 行的 `publicUrl` 时模型也能看到。插件通过 `import type {} from '@deepseek-ai/dsh-web-app'` 取得 `webApp` 类型;该导入在运行时被擦除,因此 web-app bundle 对本包的依赖仍是唯一的运行时依赖。
### 新会话
| 注册 | 内容 |
|---|---|
| section `harness:source`(`HARNESS_SOURCE` 位) | `The runtime context names the DeepSeek Harness implementation checkout. …`,其余说明不变 |
| context `harness:source`(`CONTEXT_ORDERS.HARNESS_SOURCE = 200`) | `Current DeepSeek Harness implementation checkout: "<path>".` |
| section `app:web-surface`(`WEB_SURFACE` 位) | `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. …`,其余说明不变 |
| context `app:web-surface`(`CONTEXT_ORDERS.WEB_SURFACE = 210`) | `Current DeepSeek Harness Web GUI URL: "<url>".` |
两个 context 都是可选项(不设 `required`)。唯一的 complete persona(web-app 的 minimal 预设)同时关闭了可选运行时上下文,因此它仍然看不到这两项。运行时上下文按既有机制工作:值不变不发新快照,变化时在本轮 user 消息前追加完整快照,恢复时从会话日志取上一份快照比较,不改写任何已提交消息。
### 旧格式会话
插件注册仅限宿主的会话投影 `harnessDevLegacyPrompt`,按事件顺序折叠每个非空 `system/message`,状态为 `{ source, web }`:该 prompt 中按旧模板逐字匹配到的段落,未匹配为 `null`。
| 旧模板 | 匹配规则 |
|---|---|
| `harness:source` | `The DeepSeek Harness implementation checkout is at ` 开头、旧版固定说明结尾、中间不含换行 |
| `app:web-surface` | `You are interacting with the user through the DeepSeek Harness Web GUI at ` 开头、`. ` 加早期版本渲染的说明结尾(冻结为字面量)、中间不含换行 |
两个 section 的文本提供函数读取 `AssembleContext.agent.session` 的投影状态:有旧段落时原样返回该段落(`interpolate: false`),否则返回新说明。没有 agent 的诊断性组装使用新说明。`sessionProjections` 是插件的必需注入,投影单元由插件自己注册,因此存在 agent 时状态必然可读。
旧会话因此满足两点:
| 场景 | system prompt | 运行时上下文 |
|---|---|---|
| 升级后首次恢复,端口不变 | 与日志逐字相同,loop 不产生 `system/message` | 追加含两项当前值的快照 |
| 升级后换端口、slot 或安装位置 | 仍是日志里的旧段落 | 追加新值快照 |
投影只在每次提交的 `system/message` 上计算,不新增同步读取历史事件的调用;状态随投影检查点持久化。压缩或新请求序列重写头节点时,渲染结果仍含同一旧段落,投影状态不变。
## 备选方案
**只把两项值移到运行时上下文,不保留旧段落。** 升级本身会改变 prompt 文本,所有既有 Claude 会话在升级后第一轮改写头节点并报 400。
**为手写 Anthropic 路由开放 `supportsMidConvoSystemMessages`。** 上游实测接受对话中途的 system 消息,但 pi-ai 目录已为具名 Anthropic 路由提供该能力,手写路由按既有约定不开放目录字段;而且 in-history 路由每次值变化仍追加一份完整 prompt。
**请求上游丢弃不匹配的 thinking 块(`prefix_mismatch_behavior: "drop_block"`)。** 会静默丢失历史推理,且只覆盖一个供应商。
**会话内冻结整份 system prompt。** plan 模式、技能和工具说明等合法的 prompt 变化也会被冻结,无法到达模型。
**留在 web-app 与 app-boot 中实现。** 面向开发的上下文与旧格式兼容逻辑会扩大两个核心包的公开 API;app-boot 的 `addHarnessSourceSection` 当时只有 web-app 一个调用方。
**读取 `webStartup.publicUrl`。** 它只携带 `--public-url` 参数,profile 覆盖 `web-runtime` 行的 `publicUrl` 时只会影响 `DSH_WEB_URL`,到不了模型;生效值只存在于 web-app 内部,现在由它以 `webApp` 提供。
**直接读取会话历史中的头节点文本。** 同步读取任意历史事件的 API 已弃用,新调用被禁止;投影是恢复后取得该状态的既定方式。
## 后果
- 新会话的 system prompt 不再随端口、slot 或安装位置变化;两项当前值以运行时上下文快照送达,变化时只追加几行。
- 旧会话在整个生命周期内保留旧段落中的旧值;同一请求里更晚的 `Current …` 运行时上下文给出当前值。
- 只保护这两段。其他提示文本在版本之间变化时,不支持 in-history 的路由仍会改写头节点。
- 不含本修复的旧版本恢复新格式会话时会按旧模板改写头节点;A/B 两个 checkout 都需包含本修复后再交替运行。
- app-boot 不再导出 `addHarnessSourceSection` 与 `HARNESS_SOURCE_SECTION`;web-app 的 `surfaceContext` 只控制 `DSH_WEB_URL`,关闭这两项上下文改为禁用 `harness-dev-context` 行。
- Web e2e 录制会话里的运行时上下文多出两行;测试脚手架把 URL 与 checkout 路径规范化为占位符。
## 测试
- `packages/context/harness-dev-context/tests/harness-dev-context.spec.ts`:真实 AgentLoop 上,旧格式会话在另一端口恢复时不产生 `system/message`、请求中的 system prompt 与日志逐字相同、运行时上下文携带当前 URL;再换一次端口仍成立;不支持与支持 in-history 的路由各跑一遍。series 合并只清空后续版本时,下一轮仍沿用旧段落。缺少固定结尾或跨行的相似文本得到新说明。新会话换端口后 system prompt 不变、只追加运行时上下文快照。section 不含值、context 携带公告 URL,销毁插件 fiber 后全部注册消失。
- `packages/host/webserver/tests/webserver.spec.ts`:`bindUrlHost` 各地址形式;`getOrigin()` 在 HTTP 与 HTTPS 监听上的值,绑定前与带 zone 的非回环地址抛错。
- `packages/bundle/web-app/tests/web-app.spec.ts`:`webApp.getAdvertisedUrl()` 返回监听器 origin,或该行配置的 `publicUrl`。
- `packages/core/system-prompt/tests/system-prompt.spec.ts`:新增的 context 位置唯一且为整数。
@@ -39,8 +39,8 @@
en: 761eb6c1aeb7af18
zh: 5434456a3d0a1b65
/agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/testing:
en: f5d287f610962a48
zh: 88368e1df517b087
en: 6e637b0970682265
zh: 7fed6e98f87b5f1a
/agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/alternatives-considered:
en: e78530994a88d14f
zh: 49f64c7f1421f507
@@ -101,6 +101,8 @@ Deployments switching to `'ptc'` must update any native-only `toolOrder`. Assemb
- **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a PTC mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior.
- **Snapshot:** The `ptc-turn`, `both-mode-turn`, and `ptc-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. The TypeScript SDK PTC scenario mounts its Node runtime through an explicit test-owned profile patch and pins Session events and JSON-RPC notifications; its expected response and completed-turn checks run before refresh writes.
The optional [Node in-memory execution mode](../architecture/2026-09-23-best-effort-persistent-ptc.md) permits disposable cross-call memory without restoring objects or replaying programs after restart; the default execution mode stays stateless.
## Alternatives considered
**An add-on consumer plugin with zero core changes.** Rejected because `agent/request` is call-config-only under [reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md), while transforming an assembled tool list would have to undo `toolOrder` canonicalization without owning its config and would depend on listener order. Which tools the model is offered, and in which representation, is the registry's single concern: native schemas and the SDK are two projections of one visible store.
@@ -101,6 +101,8 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认
- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 PTC mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。
- **快照:** `ptc-turn`、`both-mode-turn` 和 `ptc-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。TypeScript SDK PTC 场景通过测试拥有的显式 profile patch 挂载 Node 运行时,并固定 Session 事件与 JSON-RPC 通知;预期回复和完成轮次检查在 refresh 写入前执行。
可选的[Node 内存保留执行模式](../architecture/2026-09-23-best-effort-persistent-ptc.zh.md)允许跨调用保留可丢失的内存,不在重启后恢复对象或回放程序;默认执行模式保持无状态。
## 曾考虑的替代方案
**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../architecture/2026-07-05-reconstructable-requests.zh.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。
@@ -9,14 +9,14 @@
en: e6929494e60041a2
zh: 15bec8e8baabcab2
/agent-note-generated-third-party-notices/decision:
en: 11f3362b0f45debb
zh: 5953b0848b940b67
en: e0a339aa86f13898
zh: 9ab0c7de7bb185b5
/agent-note-generated-third-party-notices/testing:
en: 194f87f780991068
zh: 58096d2339576d20
/agent-note-generated-third-party-notices/alternatives-considered:
en: 093ddc027e296bf0
zh: 6b9e64d99dcefa11
en: 7e9d8d7864503e46
zh: 5b93a1130cfab260
/agent-note-generated-third-party-notices/consequences:
en: 860b806c5d393c98
zh: 51b4ca11e2d3802a
en: ed2210ac6c133e6c
zh: 7957cb33da842642
@@ -14,9 +14,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o
[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [shared runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section.
**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check.
One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case.
**The test lane enforces freshness.** The committed bytes are asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs, including the required `node 24 / coverage` pull-request lane — the check adds no gate process, no scheduler slot, and no separate CI step. A contributor who changes a generator input — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — runs `pnpm run gen-third-party-notices`; `pnpm run verify-third-party-notices` remains available for a standalone check. No Git hook regenerates the file: browser inputs make every package source file a generator input, so a hook would run the 16-second generator on nearly every commit, while few commits change the output.
The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the shared runtime lock's `pythonPackages`. Bundled entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The shared wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Bundled distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions.
@@ -38,7 +36,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa
**Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases.
**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost.
**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, while the assertion inside a spec the test lane already runs reports the same staleness at no additional CI cost.
**Enumerate the full transitive closure.** The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the `pnpm licenses list` renderer instead.
@@ -52,7 +50,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa
## Consequences
A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command.
Commits pay no generator run. A dependency or browser-import edit that leaves the notices stale fails the test lane with an error that names the regeneration command.
The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy.
@@ -14,9 +14,7 @@ Status: implemented
[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [共享运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。
**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。
有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除** manifest 不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。
**新鲜度由测试 lane 保证。** 已提交的字节由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件,其中包括 PR 必过的 `node 24 / coverage` lane——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。改动生成器任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——的贡献者运行 `pnpm run gen-third-party-notices`;需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。没有 Git 钩子重新生成该文件:浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每个提交都运行约 16 秒的生成器,而改变输出的提交很少。
文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及共享运行时锁文件 `pythonPackages` 中的每个分发包。内置条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。共享 wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。内置分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。
@@ -38,7 +36,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通
**保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。
**用专门的 `doc-sync`(文档同步门禁)校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位;更糟的是,它唯一的失败方式,就是在别人推完一个无关的依赖升级几分钟后,通知对方回去重跑一次生成器。改为提交时重新生成消除了这次打断,而把断言放进测试 lane 本就会跑的 spec 里,则以零额外 CI 成本保住了这项保证。
**用专门的 `doc-sync`(文档同步门禁)校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位,而测试 lane 本就会跑的 spec 里的断言能以零额外 CI 成本报告同样的过期。
**列出完整传递闭包。** 闭包有数千个包,锁文件里已带精确版本,铺开只会淹没读者真正要评估的直接依赖。文件转而指向锁文件与 `pnpm licenses list`。
@@ -52,7 +50,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通
## 后果
此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及 manifest 的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。
提交不运行生成器。改动依赖或浏览器侧 import 却未更新披露文件时,测试 lane 会失败,其报错会指明重新生成的命令。
生成器需要已安装的依赖树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。
@@ -9,8 +9,8 @@
en: e5c984fa32cc5e69
zh: 25d5742b2106d830
/agent-note-pr-artifact-builds-and-aggregate-typescript-checks/decision:
en: 6e84bceaba4573ca
zh: 25a37c147a675643
en: af93d8849bb8831a
zh: 53e15e801ab844da
/agent-note-pr-artifact-builds-and-aggregate-typescript-checks/alternatives-considered:
en: f61da066f9ddcd62
zh: 816fd6e0063017cd
@@ -12,13 +12,13 @@ PR jobs need the same compiled packages for benchmarks, compatibility, packaging
Required Linux consumer and native Windows build jobs retain normal builds, including both complete aggregate programs. Public `build` and `typecheck` keep the same checks. [Node version ownership](2026-07-06-node-engine-floor.md) and [native Windows ownership](2026-08-08-native-windows-pull-request-ci.md) remain in force.
Checked PR artifact builds compile every existing project reference from each compiler face with one serial `tsc -b` process, then run the ordinary Typert, bundling, Desktop, and Web stages. The helper reads the existing Host and Client aggregates rather than maintaining another package list. It omits only each root's additional test and script program; package diagnostics, emitted outputs, and Host-before-Client ordering remain required. Non-PR release and real-API workflow builds retain their normal checks.
Checked PR artifact builds compile every existing project reference from each compiler face with one serial `tsc -b` process, then run the ordinary Typert, bundling, Desktop, and Web stages. The helper reads the existing Host and Client aggregates rather than maintaining another package list. It omits only each root's additional test and script program; package diagnostics, emitted outputs, and Host-before-Client ordering remain required. Non-PR release and real-API workflow builds retain their normal checks. That artifact build is `pnpm run build:artifacts`; [the build split](2026-10-11-build-artifacts-check-split.md) owns its steps and scheduling.
Only the CI benchmark helper selects `tsc -b --noCheck` for package emission. Benchmarks consume JavaScript and Typert runtime metadata; their declaration and build-information files are disposable and never published. Typert analyzes source types independently, while required complete builds own semantic diagnostics. Syntax and configuration failures still reject benchmark preparation. TypeScript records pending checks so a later ordinary incremental build diagnoses the same source without deleting caches or forcing a rebuild. Public benchmark scripts and every release, Python, preview, compatibility, and normal artifact builder keep their checked defaults.
The Python executable builder selects artifact compilation with `--artifacts-only` after verifying its runtime closure. Invalid closure inputs fail before compilation, deployment, or packing; the default complete build and prebuilt `--skip-build` mode keep their existing stages.
The Python executable builder selects `build:artifacts` artifact compilation with `--build-artifacts` after verifying its runtime closure. Invalid closure inputs fail before compilation, deployment, or packing; the default complete build and prebuilt `--skip-build` mode keep their existing stages.
CI invokes the internal build helpers directly and keeps the root package manifest unchanged. Root manifest edits activate the separate native-addon platform matrix even when only build aliases change; avoiding that trigger preserves the existing workflow set and concurrent-job ceiling without weakening native validation.
PR artifact builds in CI invoke the root manifest's `build:artifacts`; a PR that edits these root manifest scripts activates the separate native-addon platform matrix, a trade-off [the build split](2026-10-11-build-artifacts-check-split.md) records. The vendor rehearsal and benchmark lanes invoke the internal build helpers directly.
Job inventory, runner selectors, and benchmark sample counts stay fixed; gate and worker ceilings do not increase. The three compatibility cases run one job at a time; the two-target Python runtime matrix starts after the keyless Python SDK job succeeds. Four Linux lanes, the Python SDK, three Windows lanes, and one compatibility job permit at most nine worker jobs initially. Once the SDK job finishes, the remaining seven independent lanes, one compatibility job, and two runtime targets permit at most ten. The runtime plan and wheel stages precede those targets and each use one job. This permits runtime validation to overlap the Node 22 build within the original ten-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order.
@@ -12,13 +12,13 @@ PR job 需要相同的编译包来运行 benchmark、兼容性、打包和运行
必需的 Linux consumer 与原生 Windows build job 保留普通构建,包括两个完整 aggregate program。公开的 `build` 和 `typecheck` 保留相同检查。[Node 版本职责](2026-07-06-node-engine-floor.zh.md)与[原生 Windows 职责](2026-08-08-native-windows-pull-request-ci.zh.md)仍然有效。
带检查的 PR 产物构建通过一个串行 `tsc -b` 进程编译各 compiler face 的全部现有 project reference,然后运行普通 Typert、bundling、Desktop 和 Web 阶段。helper 读取现有 Host 与 Client aggregate,而不维护另一份包清单。它只省略各 root 附加的测试和脚本 program;包诊断、emit 输出及 Host 先于 Client 的顺序仍然必需。非 PR 的 release 和真实 API 工作流构建保留普通检查。
带检查的 PR 产物构建通过一个串行 `tsc -b` 进程编译各 compiler face 的全部现有 project reference,然后运行普通 Typert、bundling、Desktop 和 Web 阶段。helper 读取现有 Host 与 Client aggregate,而不维护另一份包清单。它只省略各 root 附加的测试和脚本 program;包诊断、emit 输出及 Host 先于 Client 的顺序仍然必需。非 PR 的 release 和真实 API 工作流构建保留普通检查。该产物构建即 `pnpm run build:artifacts`,其步骤与调度见[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)。
只有 CI benchmark helper 选择 `tsc -b --noCheck` 来 emit 包。benchmark 消费 JavaScript 和 Typert 运行时元数据;其声明与 build-information 文件是临时产物,不会发布。Typert 独立分析源码类型,语义诊断由必需的完整构建负责。语法与配置错误仍会拒绝 benchmark 准备。TypeScript 记录待检查状态,使后续普通增量构建无需删除缓存或强制重建,就能诊断相同源码。公开 benchmark script,以及所有 release、Python、preview、compatibility 和普通产物 builder,均保留带检查的默认行为。
Python executable builder 在验证 runtime closure 后,通过 `--artifacts-only` 选择产物编译。无效 closure 在编译、部署或打包前失败;默认完整构建和复用预构建产物的 `--skip-build` 模式保留原有阶段。
Python executable builder 在验证 runtime closure 后,通过 `--build-artifacts` 选择 `build:artifacts` 产物编译。无效 closure 在编译、部署或打包前失败;默认完整构建和复用预构建产物的 `--skip-build` 模式保留原有阶段。
CI 直接调用内部 build helper,并保持 root package manifest 不变。即使只修改 build alias,root manifest 改动也会激活独立的 native-addon 平台矩阵;避免这一触发条件,可以在不削弱 native 验证的情况下保留现有 workflow 集合与并发 job 上限。
CI 的 PR 产物构建调用 root manifest 中的 `build:artifacts`;修改 root manifest 中这些 script 的 PR 会激活独立的 native-addon 平台矩阵,[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)记录这一取舍。vendor 演练与 benchmark lane 直接调用内部 build helper。
job 清单、runner 选择和 benchmark sample 数量保持固定;gate 与 worker 上限不提高。三个 compatibility case 每次运行一个 job;双目标 Python runtime 矩阵在 keyless Python SDK job 成功后启动。四个 Linux lane、Python SDK、三个 Windows lane 和一个 compatibility job,初始最多占用九个 worker job。SDK job 完成后,剩余七个独立 lane、一个 compatibility job 和两个 runtime target,最多占用十个 job。runtime plan 与 wheel 阶段先于这些 target,各占一个 job。这允许 runtime 验证与 Node 22 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。
@@ -0,0 +1,40 @@
# Bilingual-pair consistency record for 2026-10-11-build-artifacts-check-split.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/process/2026-10-11-build-artifacts-check-split.md
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently:
en: b1a98fdd8008dce1
zh: 15bdc2f2c2584226
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/problem:
en: bc382ad1f1e71352
zh: 44189bf0f69a0bf7
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision:
en: a15c7c13f167e5ac
zh: 63bcc2ef60c2f344
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/commands:
en: 174241330463841d
zh: 7fe361c07e634053
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/steps-and-dependencies:
en: 43e78e255306cd43
zh: a741d5ef1c7379f3
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/scheduling-failure-and-output:
en: 4b324f92307d3691
zh: e0cf4411a747f7e5
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/callers:
en: 55c55eea07fdb459
zh: ab78cdfa4d04a5e7
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/measurements:
en: b3a753ac2aab6073
zh: 19cbd0e32751896f
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/completeness-and-lossless-evidence:
en: 3cd40e0db9a2c89a
zh: 3f708be239779d0a
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/intentional-trade-offs-not-lossless:
en: 14370bc5b54e1278
zh: 7ac933092d3e9954
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/alternatives-considered:
en: 54905b912d17c74e
zh: 86a298004c8024e3
/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/consequences:
en: 083467f68d0f03ed
zh: 93400e9994b8c645
@@ -0,0 +1,157 @@
# Agent Note: Split build:artifacts and build:check, with build running both concurrently
Status: implemented
English | [中文](2026-10-11-build-artifacts-check-split.zh.md)
## Problem
`pnpm run build` runs the native addon, the package-library build (Host `tsc -b tsconfig.host.json` → Host tsdown → Desktop bundle → Client `tsc -b tsconfig.client.json` → Client tsdown), and the Web build serially. Besides referencing package projects, the Host and Client aggregates each carry a `noEmit` root program covering tests, scripts, benchmarks, and website. These two root programs emit no files and feed no later artifact step, yet sit on the serial critical path: timed alone, the Host root program takes 68.5 s and the Client root program 36.0 s, while the serial package-library build takes about 249 s (median of 2).
Development iterations need only artifacts; test and script type checks are needed before work is declared complete. The only way to skip the two root programs is the CI-oriented `pnpm run build --artifacts-only`, which has no local development command name and no counterpart that runs only those two checks. Meanwhile, `pnpm run build` must stay complete and detect every type error.
## Decision
### Commands
Each command does only its own job.
| Command | Responsibility | Steps | Scheduling | Client build record |
|---|---|---|---|---|
| `pnpm run build:artifacts` | Produces every artifact of `build`; package projects are fully type-checked as usual, the two aggregate root programs are not checked | native → host-src → host-bundle → desktop-bundle → client-src → client-bundle → web | One linear chain | Deleted at start, written after every step passes |
| `pnpm run build:check` | Type-checks only the two aggregate root programs | host-check, client-check | Both steps concurrent, no prerequisite steps | Neither read nor written |
| `pnpm run build` | Every step of `build:artifacts` and `build:check` | The dependency graph below | Concurrent through the run-gates scheduler | Deleted at start, written after every step passes |
`build:check` neither detects, rebuilds, nor validates `build:artifacts` outputs; when prerequisite outputs are missing or stale, the compiler's own diagnostics (for example TS6305 and TS2307) report it, and no command adds prerequisite checks, guards, or fallbacks. `build:artifacts` produces the same artifacts and record as `build` and replaces `--artifacts-only`; that flag and the `libraries` mode of `compile-referenced-projects.ts` are removed, and the Python executable builder uses `--build-artifacts`. `build:official` remains `tsx scripts/build.ts --profile official` and runs the complete `build`.
### Steps and dependencies
| Step | Command | `needs` in `build` |
|---|---|---|
| native | `pnpm run build:native-system` | — |
| host-src | `node --max-old-space-size=4096 <tsc> -b <references of tsconfig.host.json>` | native |
| host-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE host` (runs Typert) | host-src |
| desktop-bundle | `pnpm --filter @deepseek-ai/dsh-desktop run bundle` | host-bundle |
| client-src | `<tsc> -b <references of tsconfig.client.json>` | desktop-bundle |
| client-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE client` | client-src |
| web | `pnpm run build:web` | client-bundle |
| host-check | `node --max-old-space-size=4096 <tsc> -p tsconfig.host.json` | host-src |
| client-check | `<tsc> -p tsconfig.client.json` | client-src |
```text
native → host-src ─┬─ host-check ───────────────────────────────────────────────────────┐
└─ host-bundle → desktop-bundle → client-src ─┬─ client-check ───────┤ → record
└─ client-bundle → web ┘
```
- client-src must follow host-bundle: Client packages import `@deepseek-ai/dsh-*/remote`, whose types `lib/typert.remote-client.d.ts` Typert writes, inside Host tsdown in these scopes and through `scripts/generate-typert.ts` in the typecheck scope. host-check reads no Typert output; clean serial builds have always finished the Host aggregate check before Host tsdown.
- The artifact chain keeps the two non-data edges native → host-src and desktop-bundle → client-src; only the two check steps attach to the chain in parallel.
- [`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts) owns the package-library step list `libraryBuildSteps`, the references argument construction `referencedProjectsCompilerArgs` (relative paths keep the command below Windows' command-line limit), and the aggregate-check arguments `aggregateCheckArgs`. The vendor release rehearsal (`host-libraries`) and CI benchmarks (`--noCheck`) run the same step list serially.
- The check steps use project mode (`tsc -p`): it reads the declarations that references already emitted and compiles no reference, writing incremental state to the aggregates' existing `tsconfig.{host,client}.tsbuildinfo`. `build` and `build:check` run the same command, so their diagnostics are identical.
### Scheduling, failure, and output
- [`scripts/build.ts`](../../../../scripts/build.ts) owns the step table and the three scopes and executes them through `runAggregate` from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts): each step is a gate with `needs`, and shell-free spawning, per-step buffered output, fail-fast process-tree termination, Windows `taskkill`, and host signal forwarding come from the run-gates implementation. `build:artifacts` is `tsx scripts/build.ts --scope artifacts`, `build:check` is `--scope check`, and `build` omits `--scope` and runs both as one graph; `--profile` applies only to artifact scopes, and combining it with `--scope check` fails argument parsing.
- The concurrency limit is `DSH_GATE_CONCURRENCY`, defaulting to the available CPU count; the dependency graph is at most 3 wide (client-check and client-bundle can start while host-check is still running). Master serial reference lanes set `DSH_GATE_CONCURRENCY=1`, so `build` runs serially in topological order there, consistent with the [serial reference decision](2026-07-21-serial-cross-platform-ci-reference.md) and without workflow edits.
- By default (locally), every dependent of a failed step is recorded as skipped, while running steps and steps unrelated to the failure run to completion; the executed set depends only on step outcomes, so one run reports every independent type error. With `DSH_GATE_FAIL_FAST=1` (set in every building PR lane), the first failure terminates the process trees of the other running steps and records every unfinished step as skipped.
- A failed step prints `== FAILED <step> ==`, its command, the exit reason, and the step's complete buffered output, and the final summary lists failed and skipped steps; a passing step prints only its PASS line, and `DSH_GATE_VERBOSE=1` prints all output. The exit code is 1 when any step failed or was skipped, and the client build record is not written. Every step inherits the public Client environment selected by `clientBuildProcessEnvironment`.
- In CI, `build` itself is one gate of an outer run-gates aggregate; outer fail-fast termination propagates to the inner scheduler. `check:node-compat` and `check:ci:lint:contracts-ready` already nest the same way.
### Callers
| Caller | Command | Aggregate root-program checks |
|---|---|---|
| `build`, `build:official`, `Makefile` `build`, `scripts/publish-npm-baseline.ts`, `apps/desktop/scripts/package-target.ts`, `scripts/dev-bundle-registry.ts`, `release-publish.yml`, `sandbox.yml`, and the non-PR branches of `e2e.yml` and `release.yml` | `build` / `build:official` | Yes |
| `test:web`, `test:web:refresh`, `test:web:perf`, `test:web:stress`, `apps/desktop` `test:updates:local`, `test:bench` / `build:bench` | `build`; `build:bench` runs `build:native-system`, `build:lib`, and the benchmark tsdown | Yes |
| `typecheck` (run by the pre-push hook) | `build.ts --scope typecheck`: host-src beside host-typert (the bundle-free, source-only Typert pass `generate-typert.ts`) → client-src, with `tsc -b tsconfig.{host,client}.json` after each face's package compilation; no bundle | Yes |
| `dev:web` ([`scripts/dev-web.ts`](../../../../scripts/dev-web.ts)), `dev:desktop` (`apps/desktop/scripts/dev.ts`) | `build:artifacts` | None before launch; after `dev:web` launches, its `tsc -b tsconfig.client.json --watch` keeps checking the Client aggregate root program |
| Prerequisite step of `lint`, `lint:fix`, `doc-typecheck`, and the CI Typert contracts gate | Host package compilation beside the bundle-free Typert pass (`compile-referenced-projects.ts host-contracts`), without any bundle | None |
| CI node 24 / snapshots and artifacts (`check:ci:consumers`), windows node 24 / build & observational (`check:ci:windows-blocking`) | `build` | Yes, the two required lanes |
| CI node-compat 22.19 | `build:artifacts` (through run-gates) | None |
| CI python runtime | `build-exe-for-python-sdk.ts --build-artifacts` | None |
| PR rehearsals in `e2e.yml` and `release.yml` | `pnpm run build:artifacts --profile official` | None |
| `build-preview-cloudflare.yml` | `pnpm run build:artifacts` | None |
| CI node 24 / benchmarks | `run-ci-bench.ts` (`tsc -b --noCheck`) | None |
| PR rehearsal in `release-vendor.yml` | `compile-referenced-projects.ts host-libraries` | None |
| Non-PR branch of `release-vendor.yml`, `release-vendor-publish.yml` | `build:lib:host` | Host aggregate root program only |
`lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate read only Typert outputs and package declarations: both aggregates are `noEmit`, oxlint builds its own programs, `doc-typecheck` references only package projects, and none of them reads a bundle. Their prerequisite step therefore skips the Host aggregate check and every bundle; skipping the Host aggregate check and the Desktop bundle alone took it from 159.8 s to 90.9 s (see the measurements below).
The consumers lane's HMR Web test starts `dev:web` and therefore uses `build:artifacts` inside that lane; the same lane's `build` gate still runs both aggregate checks. CI does not call `build:check` separately: the two required lanes already get the same checks through `build`. The [PR artifact build decision](2026-10-05-pr-artifact-typechecks.md) keeps its required complete builds, benchmark `--noCheck`, job and worker ceilings, serial node-compat Vitest, coverage weights, and pnpm packing checks; its PR artifact build is `build:artifacts`.
### Measurements
Prototype conditions: a shared 256 vCPU Linux host (load average 23–70), Node v24.18.0, TypeScript 6.0.3, React type packages hoisted to the root, without the Typert changes of [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md); scope is the package-library build (excluding native and web). Every round starts from `pnpm run clean`, running once each in the order serial, concurrent, `build:lib`, `build:lib`, concurrent, serial; `/usr/bin/time -v` times each step, and summed process-tree RSS is sampled every 250 ms. The prototype runs the aggregate checks as `tsc -b tsconfig.{host,client}.json` once references are up to date, when build mode builds only the aggregate root program.
| Mode (medians of 2) | Wall (s) | CPU user+sys (s) | Process-tree RSS peak (GiB) |
|---|---|---|---|
| Serial `pnpm run build:lib` | 249.4 | 378.0 | 5.95 |
| Each step run separately in sequence | 255.1 | 373.8 | 5.82 |
| Concurrent by dependency graph | 172.1 | 392.1 | 8.00 |
Median wall falls from 249.4 s to 172.1 s (−77.3 s, −31%). The two runs of each step separately differ by 28 s, so the overhead of running the steps as separate processes is not resolvable. CPU grows by about 14 s (+3.7%), mostly Host aggregate check user time under contention; the RSS peak grows by about 2.1 GiB where the Host aggregate check (about 4 GiB) overlaps Host tsdown (about 5.7 GiB). The concurrent critical path is host-src → host-bundle → desktop-bundle → client-src → client-check; host-check finishes about 33 s before the end and client-bundle about 21 s before.
Final measurements: master (the merge base) and this branch run interleaved, each from clean, in a 64-core cgroup; values are medians of 2 samples unless marked n=1.
| Scenario | master | This branch | CPU user+sys (master → branch) | Process-tree RSS peak (master → branch) |
|---|---|---|---|---|
| Complete build `pnpm run build` | 317.1 s | 169.1 s (−46.7%) | 423.1 → 374.4 s (−11.5%) | 6.11 → 7.53 GiB (+1.42) |
| Serial branch build `DSH_GATE_CONCURRENCY=1 pnpm run build` (n=1) | — | 266.7 s | 389.5 s | 6.27 GiB |
| Artifact build: master `build --artifacts-only`, branch `build:artifacts` | 185.8 s | 153.6 s (−17.3%) | 256.2 → 231.2 s | 6.58 → 6.18 GiB |
| Development iteration: master `build`, branch `build:artifacts` | 317.1 s | 153.6 s (−51.6%) | 423.1 → 231.2 s (−45.4%) | 6.11 → 6.18 GiB |
| `pnpm run build:check` after `build:artifacts` | — | 67.7 s | 144.6 s | 5.33 GiB |
| `pnpm run build:check` no-op rerun | — | 19.4 s | 33.1 s | Not measured |
| Prerequisite of `lint` and `doc-typecheck`: master `build:lib:host`, branch `host-libraries` (since replaced by `host-contracts`, see [local gate speed-ups](2026-10-12-local-gate-speedups.md)) | 159.8 s | 90.9 s (−43.2%) | 234.7 → 136.0 s | Not measured |
The serial branch sample overlapped other work on the host (742 s of foreign CPU), so its wall likely overstates the serial cost. In the serial build segments (master → branch, one sample each), Host tsdown falls from 22.5 to 11.7 s, Client package compilation from 55.6 to 48.7 s, the Client aggregate check from 68.2 to 35.9 s, and Client tsdown from 13.9 to 10.5 s; the segments sum to 308.5 s and 254.9 s. Those reductions come from [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md); the further reduction from 254.9 s of serial segments to the 169.1 s concurrent build comes from this note's scheduling, which takes the Host aggregate check (about 66 s) off the critical path. In the concurrent build, the critical path is native → host-src → host-bundle → desktop-bundle → client-src → client-check, and host-check ends about 30 s before the last step (one earlier sample). `build:check` runs the Host and Client aggregate checks concurrently, so its wall is the Host check's (about 65 s).
### Completeness and lossless evidence
`build` runs the same set of compiler programs as the serial build: host-src and client-src compile every package project of both faces, and host-check and client-check check both aggregate root programs with the same commands as `build:check`. Across the prototype's six rounds, the 11,580 files of `{packages,apps,vendor,native}/**/lib/**` (excluding `*.tsbuildinfo`) are byte-identical between serial and concurrent runs, and after every round `tsc -b --dry tsconfig.host.json tsconfig.client.json` reports no out-of-date project. tsbuildinfo writers are host-src (292), client-src (76, exactly the client-only projects), and one per aggregate check; client-src rewrites no tsbuildinfo or declaration that host-check reads, so the graph has no write race. The fixture tests `checks only the aggregate program in project mode` and `reports missing package declarations without compiling references` show that `tsc -b --dry` treats the tsbuildinfo written by `tsc -p` as up to date and that project mode compiles no reference. In this implementation, the concurrent `build` and `DSH_GATE_CONCURRENCY=1 pnpm run build` produce byte-identical `lib/` (11,584 files), `apps/web/dist` (156 files), and client build record; afterwards `tsc -b --dry` reports all 372 projects of both aggregates up to date, with none to build.
| Error | `build:artifacts` | `build:check` | `build` | `typecheck` | Required CI lanes |
|---|---|---|---|---|---|
| Package source type error | host-src / client-src | Not reported (reads existing declarations) | host-src / client-src | Reported | Reported |
| Type error only in an aggregate root program's tests, scripts, benchmarks, or website | Not reported | host-check / client-check | host-check / client-check | Reported | consumers, windows-build |
| Package tsconfig configuration error | host-src / client-src | Not reported (`tsc -p` does not validate referenced projects' tsconfig options or syntax) | host-src / client-src | Reported | Reported |
| Typert analysis error | host-bundle | Not reported | host-bundle | Reported (through host-typert) | Reported |
| Missing prerequisite output | — | Compiler diagnostic (TS6305, TS2307) | — | — | — |
Negative controls: after a type error (TS2322) is added to `scripts/pnpm-invocation.spec.ts`, which only the Host aggregate root program contains, `build:artifacts` exits 0; `build:check` exits 1 with the Host aggregate check failing and the Client aggregate check passing; `build` exits 1 with 8 of 9 steps passing and 1 failing, and writes no client build record. In a scratch fixture whose referenced project's tsconfig has an invalid `target`, a JSON syntax error, or an unknown compiler option, `tsc -p` exits 0, while `tsc -b` reports TS6046, TS5092 with TS1012, and TS5023 respectively and exits 1; when the referenced project lacks `composite`, both report TS6306.
CI evidence: on PR #6005 both required lanes that run the concurrent `pnpm run build` passed, `node 24 / snapshots and artifacts` (`check:ci:consumers` on `blacksmith-16vcpu-ubuntu-2404`) and `windows node 24 / build & observational` (`check:ci:windows-blocking` on `blacksmith-16vcpu-windows-2025`); the GitHub-hosted 4 vCPU benchmarks lane does not run `build`. The inner concurrency follows `DSH_GATE_CONCURRENCY`: master serial lanes set 1, the consumers lane sets 10 under self-hosted failover, and an unset value selects the available CPU count, capped at the 9 steps.
### Intentional trade-offs (not lossless)
- **Some commands skip the aggregate checks:** `build:artifacts`, the launch builds of `dev:web` and `dev:desktop`, and the prerequisite steps of `lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate do not type-check the two aggregate root programs, and these prerequisite steps produce declarations and Typert outputs but no bundle. `build`, `build:check`, `typecheck` (pre-push hook), and the two required CI lanes own completeness; `dev:web` and `dev:desktop` do not refuse to launch because of test type errors.
- **`build:check` does not check freshness:** When sources change after `build:artifacts`, `build:check` checks against stale declarations, including Typert Remote declarations; rerun `build:artifacts` first. The complete `build` is unaffected.
- **Concurrent `build` raises the memory peak:** This branch's complete `build` peaks at 7.53 GiB of process-tree RSS against 6.11 GiB for the master serial build (+1.42 GiB, medians of 2) and 6.27 GiB for the branch's serial build (n=1); CPU meanwhile falls from 423.1 s to 374.4 s because [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md) removes more work than concurrent contention adds. In the prototype (without the Typert changes), concurrent scheduling alone raises the RSS peak by about 2.1 GiB (5.9 → 8.0 GiB) and CPU by about 3.7%. `DSH_GATE_CONCURRENCY` bounds it; under memory pressure use `DSH_GATE_CONCURRENCY=1 pnpm run build`, or run `build:artifacts` and `build:check` separately. In the windows-build lane, `build` runs concurrently with `docs:build`.
- **Passing steps' output is hidden by default:** Only failed steps print their complete output.
- **`tsc -p` does not skip an up-to-date root program:** An immediate rerun of `build:check` without changes still takes 19.4 s and 33.1 s of CPU because both programs are parsed again; `tsc -b` skips an up-to-date root.
- **On many-core cold builds `build:artifacts` is only 15.5 s faster than `build`:** This branch's cold `build` takes 169.1 s and `build:artifacts` 153.6 s (medians of 2), because host-check is off the critical path and client-check is only about 16 s longer than client-bundle plus web. The rest of `build:artifacts`'s saving is CPU and memory: 374.4 → 231.2 s of CPU and 7.53 → 6.18 GiB of peak RSS; on few-core machines and in incremental rebuilds this also turns into wall time.
- **CI invokes public commands in the root manifest:** PR artifact builds invoke `build:artifacts` rather than an internal helper flag; a PR that edits these root manifest scripts activates the separate Node Addon System platform matrix.
## Alternatives considered
- **Keep the aggregate checks only in `typecheck`:** `pnpm run build` must stay complete and detect every error.
- **Keep `--artifacts-only` and add a `build:artifacts` alias:** One operation would have two names.
- **Name the commands `build:src` and `build:test`:** `build:artifacts` produces every artifact, including the tsdown bundles, the Desktop bundle, and Web, not only compiled sources; `build:check` checks scripts, benchmarks, and website code as well as tests.
- **Name the commands after compiler terms (`build:b`/`build:p`, `build:project`, `build:emit`) or `build:dist`:** tsc runs in only two artifact steps, and `-p` is an implementation choice for the check steps; TypeScript calls the `tsc -p` side the project; `emit` reads as the emit-only `--noCheck` mode although package projects stay fully checked; package outputs live under `lib/`, and `dist` suggests release packaging.
- **`build:check` runs `tsc -b <aggregate>`:** Build mode compiles out-of-date references, doing `build:artifacts`'s work.
- **`build:check` first completes or validates prerequisite outputs:** It violates each command doing only its own job and adds prerequisite logic; compiler diagnostics already name missing outputs.
- **A new scheduler (shell `&`/`wait`, `concurrently`, turbo/nx):** It duplicates run-gates' failure summary, process-tree termination, and Windows handling.
- **Expand `build` into several gates in CI's run-gates:** `pnpm run build` must be a complete command usable directly locally and by release workflows, and it writes the record.
- **Relax the non-data edges in the artifact chain (native ∥ host-src, desktop-bundle ∥ client-src):** Not measured; the Desktop bundle itself takes only 4.0–4.5 s.
- **Move tests into a new tsconfig:** The existing aggregates already separate package references from the root program.
- **Split the aggregate root programs into sub-programs:** Each sub-program must inject the 242 files carrying module augmentations, CPU grows by 9–57%, and only wall falls (see [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md)).
- **Run `build:check` separately in CI:** The required lanes already get the same checks through `build`; a separate run repeats the work.
## Consequences
Development iterations can skip the two aggregate root programs with `build:artifacts` (51.6% faster cold than master's `build`) and complete the checks with `build:check` or `build` before declaring work complete. `build` stays complete: the Host aggregate check is off the critical path, the Client aggregate check runs alongside the Client bundle and web, and a cold build is 46.7% faster than master (including the lossless speed-ups note's changes; medians of 2). The costs are a higher memory peak, a little extra CPU from concurrent contention, less passing output by default, and several development and prerequisite commands that do not check test types (see the trade-offs above).
Removing or changing `build` in the two required lanes, consumers and windows-build, requires first transferring both aggregate checks to another blocking owner. The typecheck scope and the lint prerequisites generate Typert contracts without bundling ([local gate speed-ups](2026-10-12-local-gate-speedups.md)); the artifact scopes keep generating them inside Host tsdown.
client-src's dependency on host-bundle guarantees the "Host and Remote artifacts complete before Client compilation" ordering that [Typert Remote method calls](../architecture/2026-08-02-typert-remote-method-calls.md) describes.
Verification: `scripts/build.spec.ts` covers the type-stripping check, rejection of the removed flag and invalid scopes, the conflict between `--profile` and `--scope check`, scope selection, step dependencies, and the environment; `compile-referenced-projects.spec.ts` covers project mode checking only the aggregate program, the diagnostic for missing declarations, and `tsc -b --dry` accepting the tsbuildinfo that `tsc -p` writes; failure propagation, output buffering, and process-tree termination come from the run-gates scheduler and its tests; `run-gates.spec.ts`, `ci-workflow.spec.ts`, `preview-workflow.spec.ts`, `build-exe-for-python-sdk.spec.ts`, `dev-web.spec.ts`, and `tests/ci-release-selfhosted.spec.ts` pin each caller's command.
@@ -0,0 +1,157 @@
# Agent Note: 拆分 build:artifacts 与 build:check,build 并发执行两者
Status: implemented
[English](2026-10-11-build-artifacts-check-split.md) | 中文
## 问题
`pnpm run build` 串行执行 native addon、包库构建(Host `tsc -b tsconfig.host.json` → Host tsdown → Desktop bundle → Client `tsc -b tsconfig.client.json` → Client tsdown)和 Web 构建。Host 与 Client 聚合配置除了引用各包 project,还各自带有一个 `noEmit` 根程序,覆盖 tests、scripts、benchmarks 与 website。这两个根程序不产出任何文件,也不是后续产物步骤的输入,却位于串行关键路径上:单独计时时 Host 根程序为 68.5 s、Client 根程序为 36.0 s,串行包库构建约为 249 s(2 个样本的中位数)。
开发迭代只需要产物,测试与脚本的类型检查在声明完成前才需要。能跳过两个根程序的只有面向 CI 的 `pnpm run build --artifacts-only`;它没有本地开发命令名,也没有只做这两项检查的对应命令。同时,`pnpm run build` 必须保持完整,能检测出所有类型错误。
## 决策
### 命令
每个命令只做自己的工作。
| 命令 | 职责 | 步骤 | 调度 | client build record |
|---|---|---|---|---|
| `pnpm run build:artifacts` | 产出 `build` 的全部产物;包 project 照常完整类型检查,不检查两个聚合根程序 | native → host-src → host-bundle → desktop-bundle → client-src → client-bundle → web | 一条线性链 | 开始时删除,全部通过后写入 |
| `pnpm run build:check` | 只类型检查两个聚合根程序 | host-check、client-check | 两步并发,无前置步骤 | 不读写 |
| `pnpm run build` | `build:artifacts` 与 `build:check` 的全部步骤 | 下文依赖图 | run-gates 调度器并发执行 | 开始时删除,全部通过后写入 |
`build:check` 不检测、不重建、也不校验 `build:artifacts` 的产物;前置产物缺失或过期时,由编译器自身的诊断(例如 TS6305、TS2307)报告,任何命令都不增加前置检查、守卫或回退逻辑。`build:artifacts` 产出的产物与 record 和 `build` 相同,并取代 `--artifacts-only`;该参数与 `compile-referenced-projects.ts` 的 `libraries` 模式已删除,Python executable builder 使用 `--build-artifacts`。`build:official` 仍是 `tsx scripts/build.ts --profile official`,执行完整的 `build`。
### 步骤与依赖
| 步骤 | 命令 | `build` 中的 `needs` |
|---|---|---|
| native | `pnpm run build:native-system` | — |
| host-src | `node --max-old-space-size=4096 <tsc> -b <tsconfig.host.json 的 references>` | native |
| host-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE host`(运行 Typert) | host-src |
| desktop-bundle | `pnpm --filter @deepseek-ai/dsh-desktop run bundle` | host-bundle |
| client-src | `<tsc> -b <tsconfig.client.json 的 references>` | desktop-bundle |
| client-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE client` | client-src |
| web | `pnpm run build:web` | client-bundle |
| host-check | `node --max-old-space-size=4096 <tsc> -p tsconfig.host.json` | host-src |
| client-check | `<tsc> -p tsconfig.client.json` | client-src |
```text
native → host-src ─┬─ host-check ───────────────────────────────────────────────────────┐
└─ host-bundle → desktop-bundle → client-src ─┬─ client-check ───────┤ → record
└─ client-bundle → web ┘
```
- client-src 必须在 host-bundle 之后:Client 包导入 `@deepseek-ai/dsh-*/remote`,其类型 `lib/typert.remote-client.d.ts` 由 Typert 写出:这些 scope 在 Host tsdown 中生成,typecheck scope 经 `scripts/generate-typert.ts` 生成。host-check 不读取 Typert 输出,干净的串行构建一直在 Host tsdown 之前完成 Host 聚合检查。
- 产物链保留 native → host-src 与 desktop-bundle → client-src 这两条非数据依赖;只有两个 check 步骤并行挂接到链上。
- [`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts) 拥有包库步骤列表 `libraryBuildSteps`、references 参数构造 `referencedProjectsCompilerArgs`(相对路径传参,避开 Windows 命令行长度上限)与聚合检查参数 `aggregateCheckArgs`。vendor 发布演练(`host-libraries`)与 CI benchmark(`--noCheck`)串行执行同一步骤列表。
- check 步骤使用 project mode(`tsc -p`):它读取 references 已有的声明而不编译任何 reference,incremental 状态写入聚合配置已有的 `tsconfig.{host,client}.tsbuildinfo`。`build` 与 `build:check` 使用同一条命令,因此诊断相同。
### 调度、失败与输出
- [`scripts/build.ts`](../../../../scripts/build.ts) 拥有步骤表与三个范围,执行复用 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 的 `runAggregate`:每个步骤是一个带 `needs` 的 gate,shell-free 启动、按步骤缓冲输出、fail-fast 进程树终止、Windows `taskkill` 与宿主信号转发都沿用 run-gates 的实现。`build:artifacts` 为 `tsx scripts/build.ts --scope artifacts`,`build:check` 为 `--scope check`,`build` 不带 `--scope`,把两者作为一张图执行;`--profile` 只适用于产生产物的范围,与 `--scope check` 同用时参数解析失败。
- 并发上限取 `DSH_GATE_CONCURRENCY`,未设置时为可用 CPU 数;依赖图宽度最多为 3(host-check 尚未结束时,client-check 与 client-bundle 可能已开始)。master 串行参考 lane 设置 `DSH_GATE_CONCURRENCY=1`,`build` 在那里按拓扑顺序串行执行,符合[串行参考决策](2026-07-21-serial-cross-platform-ci-reference.zh.md),无需修改 workflow。
- 缺省(本地)时,失败步骤的所有依赖方记为 skipped,正在运行的步骤和与失败无关的步骤继续运行到结束;执行集合只取决于各步骤的结果,因此一次运行报告所有相互独立的类型错误。`DSH_GATE_FAIL_FAST=1`(所有构建型 PR lane 已设置)时,第一个失败终止其余正在运行步骤的进程树,所有未完成步骤记为 skipped。
- 失败步骤打印 `== FAILED <步骤> ==`、命令、退出原因与该步骤完整的缓冲输出,结尾汇总列出 failed 与 skipped 步骤;成功步骤只打印 PASS 行,`DSH_GATE_VERBOSE=1` 打印全部输出。任一步骤 failed 或 skipped 时退出码为 1,且不写 client build record。所有步骤继承 `clientBuildProcessEnvironment` 选定的公开 Client 环境。
- CI 中 `build` 本身是外层 run-gates 的一个 gate;外层 fail-fast 终止会转发到内层调度。`check:node-compat` 与 `check:ci:lint:contracts-ready` 已使用同样的嵌套。
### 调用方
| 调用方 | 命令 | 聚合根程序检查 |
|---|---|---|
| `build`、`build:official`、`Makefile` `build`、`scripts/publish-npm-baseline.ts`、`apps/desktop/scripts/package-target.ts`、`scripts/dev-bundle-registry.ts`、`release-publish.yml`、`sandbox.yml`、`e2e.yml` 与 `release.yml` 的非 PR 分支 | `build` / `build:official` | 有 |
| `test:web`、`test:web:refresh`、`test:web:perf`、`test:web:stress`、`apps/desktop` `test:updates:local`、`test:bench` / `build:bench` | `build`;`build:bench` 运行 `build:native-system`、`build:lib` 与 benchmark tsdown | 有 |
| `typecheck`(pre-push hook 运行它) | `build.ts --scope typecheck`:host-src 与 host-typert(不打包、只读源码的 Typert 生成 `generate-typert.ts`)并行 → client-src,各 face 包编译后运行 `tsc -b tsconfig.{host,client}.json`;不含任何 bundle | 有 |
| `dev:web`([`scripts/dev-web.ts`](../../../../scripts/dev-web.ts))、`dev:desktop`(`apps/desktop/scripts/dev.ts`) | `build:artifacts` | 启动前无;`dev:web` 启动后的 `tsc -b tsconfig.client.json --watch` 持续检查 Client 聚合根程序 |
| `lint`、`lint:fix`、`doc-typecheck` 与 CI 的 Typert contracts gate 的前置步骤 | Host 包编译与不打包的 Typert 生成并行(`compile-referenced-projects.ts host-contracts`),不含任何 bundle | 无 |
| CI node 24 / snapshots and artifacts(`check:ci:consumers`)、windows node 24 / build & observational(`check:ci:windows-blocking`) | `build` | 有,两个必需 lane |
| CI node-compat 22.19 | `build:artifacts`(经 run-gates) | 无 |
| CI python runtime | `build-exe-for-python-sdk.ts --build-artifacts` | 无 |
| `e2e.yml`、`release.yml` 的 PR 演练 | `pnpm run build:artifacts --profile official` | 无 |
| `build-preview-cloudflare.yml` | `pnpm run build:artifacts` | 无 |
| CI node 24 / benchmarks | `run-ci-bench.ts`(`tsc -b --noCheck`) | 无 |
| `release-vendor.yml` 的 PR 演练 | `compile-referenced-projects.ts host-libraries` | 无 |
| `release-vendor.yml` 的非 PR 分支、`release-vendor-publish.yml` | `build:lib:host` | 仅 Host 聚合根程序 |
`lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 只读取 Typert 输出与包声明:两个聚合配置都是 `noEmit`,oxlint 自行构建程序,`doc-typecheck` 只引用包 project,它们都不读取 bundle。它们的前置步骤因此跳过 Host 聚合检查与所有 bundle;仅跳过 Host 聚合检查与 Desktop bundle 就使耗时从 159.8 s 降到 90.9 s(见下文测量)。
consumers lane 的 HMR Web 测试启动 `dev:web`,因此在该 lane 内使用 `build:artifacts`;同一 lane 的 `build` gate 仍运行两个聚合检查。CI 不单独调用 `build:check`:两个必需 lane 已通过 `build` 获得同一组检查。[PR 产物构建决策](2026-10-05-pr-artifact-typechecks.zh.md)的必需完整构建、benchmark `--noCheck`、job 与 worker 上限、node-compat 串行 Vitest、覆盖率权重与 pnpm 打包检查仍然有效;其中的 PR 产物构建由 `build:artifacts` 执行。
### 测量
原型测量的条件:256 vCPU 共享 Linux 主机(load average 23–70),Node v24.18.0、TypeScript 6.0.3,React 类型包已提升到根,不含[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md) 的 Typert 改动;范围是包库构建(不含 native 与 web)。每轮从 `pnpm run clean` 开始,按 串行、并发、`build:lib`、`build:lib`、并发、串行 的顺序各运行一次;每个步骤由 `/usr/bin/time -v` 计时,进程树 RSS 每 250 ms 采样求和。原型中的聚合检查以 `tsc -b tsconfig.{host,client}.json` 在 references 已是最新时运行,此时 build mode 只构建聚合根程序。
| 方式(2 个样本的中位数) | wall(s) | CPU user+sys(s) | 进程树 RSS 峰值(GiB) |
|---|---|---|---|
| 串行 `pnpm run build:lib` | 249.4 | 378.0 | 5.95 |
| 各步骤依次单独运行 | 255.1 | 373.8 | 5.82 |
| 按依赖图并发 | 172.1 | 392.1 | 8.00 |
wall 中位数从 249.4 s 降到 172.1 s(−77.3 s,−31%)。各步骤单独运行的两次样本相差 28 s,因此把步骤拆成独立进程的开销无法分辨。CPU 增加约 14 s(+3.7%),主要是争用下 Host 聚合检查增加的 user 时间;RSS 峰值增加约 2.1 GiB,出现在 Host 聚合检查(约 4 GiB)与 Host tsdown(约 5.7 GiB)重叠时。并发时的关键路径是 host-src → host-bundle → desktop-bundle → client-src → client-check;host-check 在结束前约 33 s 完成,client-bundle 在结束前约 21 s 完成。
最终测量:master(合并基点)与本分支交替运行,每次均从 clean 开始,在 64 核 cgroup 中运行;除标注 n=1 者外,数值均为 2 个样本的中位数。
| 场景 | master | 本分支 | CPU user+sys(master → 本分支) | 进程树 RSS 峰值(master → 本分支) |
|---|---|---|---|---|
| 完整构建 `pnpm run build` | 317.1 s | 169.1 s(−46.7%) | 423.1 → 374.4 s(−11.5%) | 6.11 → 7.53 GiB(+1.42) |
| 本分支串行构建 `DSH_GATE_CONCURRENCY=1 pnpm run build`(n=1) | — | 266.7 s | 389.5 s | 6.27 GiB |
| 产物构建:master `build --artifacts-only`,本分支 `build:artifacts` | 185.8 s | 153.6 s(−17.3%) | 256.2 → 231.2 s | 6.58 → 6.18 GiB |
| 开发迭代:master `build`,本分支 `build:artifacts` | 317.1 s | 153.6 s(−51.6%) | 423.1 → 231.2 s(−45.4%) | 6.11 → 6.18 GiB |
| `pnpm run build:check`(`build:artifacts` 之后) | — | 67.7 s | 144.6 s | 5.33 GiB |
| `pnpm run build:check` 无改动重跑 | — | 19.4 s | 33.1 s | 未测量 |
| `lint` 与 `doc-typecheck` 的前置步骤:master `build:lib:host`,本分支 `host-libraries`(后由 `host-contracts` 取代,见[本地门禁提速](2026-10-12-local-gate-speedups.zh.md)) | 159.8 s | 90.9 s(−43.2%) | 234.7 → 136.0 s | 未测量 |
本分支串行样本运行时主机上有其他工作(外来 CPU 742 s),因此它的 wall 可能高估串行成本。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;各段合计分别为 308.5 s 与 254.9 s。这些缩短来自[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md);从 254.9 s 的串行各段合计到 169.1 s 的并发构建,其余缩短来自本 Note 的调度,它把 Host 聚合检查(约 66 s)移出关键路径。并发构建的关键路径是 native → host-src → host-bundle → desktop-bundle → client-src → client-check,host-check 在最后一步结束前约 30 s 完成(取自更早的一个样本)。`build:check` 并发运行 Host 与 Client 聚合检查,因此其 wall 等于 Host 聚合检查的 wall(约 65 s)。
### 完整性与无损依据
`build` 运行的编译器程序集合与串行构建相同:host-src 与 client-src 编译两个 face 的全部包 project,host-check 与 client-check 检查两个聚合根程序,且使用与 `build:check` 相同的命令。原型的 6 轮运行中,`{packages,apps,vendor,native}/**/lib/**`(排除 `*.tsbuildinfo`)的 11,580 个文件在串行与并发之间逐字节一致,每轮之后 `tsc -b --dry tsconfig.host.json tsconfig.client.json` 报告没有过期 project。tsbuildinfo 写入方归属为:host-src 292 个,client-src 76 个(恰好是 client-only project),两个聚合检查各 1 个;client-src 不改写 host-check 读取的任何 tsbuildinfo 或声明,因此该依赖图中不存在写入竞争。fixture 测试 `checks only the aggregate program in project mode` 表明 `tsc -p` 写出的 tsbuildinfo 被 `tsc -b --dry` 视为最新,`reports missing package declarations without compiling references` 表明 project mode 不编译 references。本实现中,并发 `build` 与 `DSH_GATE_CONCURRENCY=1 pnpm run build` 产出逐字节一致的 `lib/`(11,584 个文件)、`apps/web/dist`(156 个文件)与 client build record;之后 `tsc -b --dry` 对两个聚合配置报告 372 个 project 全部为最新,没有需要构建的 project。
| 错误 | `build:artifacts` | `build:check` | `build` | `typecheck` | 必需 CI lane |
|---|---|---|---|---|---|
| 包源码类型错误 | host-src / client-src | 不报告(读取已有声明) | host-src / client-src | 报告 | 报告 |
| 只属于聚合根程序的测试、脚本、benchmark、website 类型错误 | 不报告 | host-check / client-check | host-check / client-check | 报告 | consumers、windows-build |
| 包 tsconfig 配置错误 | host-src / client-src | 不报告(`tsc -p` 不校验被引用 project 的 tsconfig 选项与语法) | host-src / client-src | 报告 | 报告 |
| Typert 分析错误 | host-bundle | 不报告 | host-bundle | 报告(经 host-typert) | 报告 |
| 前置产物缺失 | — | 编译器诊断(TS6305、TS2307) | — | — | — |
负对照:在只属于 Host 聚合根程序的 `scripts/pnpm-invocation.spec.ts` 中加入类型错误(TS2322)后,`build:artifacts` 以 0 退出;`build:check` 以 1 退出,其中 Host 聚合检查失败、Client 聚合检查通过;`build` 以 1 退出,9 个步骤中 8 个通过、1 个失败,且不写 client build record。在临时 fixture 中,被引用 project 的 tsconfig 含无效 `target`、JSON 语法错误或未知编译选项时,`tsc -p` 以 0 退出,`tsc -b` 分别报告 TS6046、TS5092 与 TS1012、TS5023 并以 1 退出;被引用 project 缺少 `composite` 时,两者都报告 TS6306。
CI 证据:PR #6005 上,两个运行并发 `pnpm run build` 的必需 lane 均通过:`node 24 / snapshots and artifacts`(`check:ci:consumers`,运行于 `blacksmith-16vcpu-ubuntu-2404`)与 `windows node 24 / build & observational`(`check:ci:windows-blocking`,运行于 `blacksmith-16vcpu-windows-2025`);GitHub 托管的 4 vCPU benchmarks lane 不运行 `build`。内层并发取 `DSH_GATE_CONCURRENCY`:master 串行 lane 设为 1,consumers lane 在 self-hosted failover 时设为 10,未设置时取可用 CPU 数,且不超过 9 个步骤。
### 有意的取舍(非无损)
- **部分命令跳过聚合检查:** `build:artifacts`、`dev:web` 与 `dev:desktop` 的启动构建,以及 `lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 的前置步骤,都不类型检查两个聚合根程序,这些前置步骤只产出声明与 Typert 输出,不打包。完整性由 `build`、`build:check`、`typecheck`(pre-push hook)与两个必需 CI lane 负责;`dev:web` 与 `dev:desktop` 不会因测试类型错误而拒绝启动。
- **`build:check` 不检查新鲜度:** 在 `build:artifacts` 之后修改源码时,`build:check` 基于旧声明(包括 Typert Remote 声明)检查;需要先重跑 `build:artifacts`。完整 `build` 不受影响。
- **并发 `build` 提高内存峰值:** 本分支完整 `build` 的进程树 RSS 峰值为 7.53 GiB,master 串行构建为 6.11 GiB(+1.42 GiB,2 个样本的中位数),本分支串行构建为 6.27 GiB(n=1);同时 CPU 从 423.1 s 降到 374.4 s,因为[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md) 省去的工作多于并发争用增加的工作。原型(不含 Typert 改动)中,单独的并发调度使 RSS 峰值增加约 2.1 GiB(5.9 → 8.0 GiB)、CPU 增加约 3.7%。上限由 `DSH_GATE_CONCURRENCY` 控制;内存紧张时使用 `DSH_GATE_CONCURRENCY=1 pnpm run build`,或分别运行 `build:artifacts` 与 `build:check`。windows-build lane 中 `build` 与 `docs:build` 同时运行。
- **成功步骤的输出默认不显示:** 只有失败步骤打印完整输出。
- **`tsc -p` 不跳过已是最新的根程序:** 无改动时立即重跑 `build:check` 仍需 19.4 s 与 33.1 s CPU,因为两个程序都要重新解析;`tsc -b` 会跳过已是最新的根。
- **多核冷启动时 `build:artifacts` 只比 `build` 快 15.5 s:** 本分支冷启动 `build` 为 169.1 s,`build:artifacts` 为 153.6 s(2 个样本的中位数),因为 host-check 不在关键路径上,client-check 只比 client-bundle 与 web 之和长约 16 s。`build:artifacts` 的其余收益是 CPU 与内存:CPU 从 374.4 s 降到 231.2 s,RSS 峰值从 7.53 GiB 降到 6.18 GiB;在少核机器与增量重建中,这部分也转化为 wall。
- **CI 调用根 manifest 中的公开命令:** PR 产物构建调用 `build:artifacts`,而不是内部 helper 参数;修改根 manifest 中这些 script 的 PR 会激活独立的 Node Addon System 平台矩阵。
## 考虑过的替代方案
- **只在 `typecheck` 中保留聚合检查:** `pnpm run build` 必须完整,能检测出所有错误。
- **保留 `--artifacts-only` 并新增 `build:artifacts` 别名:** 同一件事有两个名字。
- **命名为 `build:src` 与 `build:test`:** `build:artifacts` 产出全部产物,包括 tsdown bundle、Desktop bundle 与 Web,而不只是编译后的源码;`build:check` 除测试外还检查脚本、benchmarks 与 website 代码。
- **按编译器术语命名(`build:b`/`build:p`、`build:project`、`build:emit`)或命名为 `build:dist`:** tsc 只出现在两个产物步骤中,`-p` 只是 check 步骤的实现选择;TypeScript 把 `tsc -p` 一侧称为 project;`emit` 容易被理解为只 emit 的 `--noCheck` 模式,而包 project 仍完整检查;包产物位于 `lib/`,`dist` 则让人联想到发布打包。
- **`build:check` 使用 `tsc -b <aggregate>`:** build mode 会编译过期的 references,即执行 `build:artifacts` 的工作。
- **`build:check` 先补齐或校验前置产物:** 违背每个命令只做自己的工作,并增加前置逻辑;编译器诊断已经指明缺失的输出。
- **新的调度实现(shell `&`/`wait`、`concurrently`、turbo/nx):** 重复 run-gates 已有的失败汇总、进程树终止与 Windows 处理。
- **在 CI 的 run-gates 中把 `build` 展开为多个 gate:** `pnpm run build` 必须是本地与 release workflow 可直接使用的完整命令,record 也由它写入。
- **放宽产物链内的非数据依赖(native ∥ host-src、desktop-bundle ∥ client-src):** 未测量;Desktop bundle 本身只占 4.0–4.5 s。
- **把测试移入新的 tsconfig:** 现有聚合配置已经把包 references 与根程序分开。
- **把聚合根程序拆成多个子程序:** 每个子程序都要注入 242 个含模块增强的文件,CPU 增加 9–57%,只降低 wall(见[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md))。
- **CI 单独运行 `build:check`:** 必需 lane 已通过 `build` 获得同一组检查,再单独运行是重复工作。
## 后果
开发迭代可以用 `build:artifacts` 跳过两个聚合根程序(冷启动比 master 的 `build` 快 51.6%),在声明完成前用 `build:check` 或 `build` 补齐检查。`build` 保持完整:Host 聚合检查不在关键路径上,Client 聚合检查与 Client bundle、web 并行,冷启动比 master 快 46.7%(含无损提速 Note 的改动,2 个样本的中位数)。代价是更高的内存峰值、并发争用带来的少量额外 CPU、默认更少的成功输出,以及多个开发与前置命令不检查测试类型(见上文取舍)。
删除或改变 consumers 与 windows-build 两个必需 lane 的 `build`,必须先把两个聚合检查转交另一处阻塞型 owner。typecheck scope 与 lint 类前置步骤不打包即生成 Typert 约定([本地门禁提速](2026-10-12-local-gate-speedups.zh.md));产物 scope 仍在 Host tsdown 中生成。
[Typert Remote 方法调用](../architecture/2026-08-02-typert-remote-method-calls.zh.md)描述的“Client 编译前先完成 Host 与 Remote 产物”由 client-src 对 host-bundle 的依赖保证。
验证:`scripts/build.spec.ts` 覆盖类型剥离检查、已删除参数与非法范围的拒绝、`--profile` 与 `--scope check` 的冲突、范围选择、步骤依赖与环境;`compile-referenced-projects.spec.ts` 覆盖 project mode 只检查聚合程序、缺失声明的诊断,以及 `tsc -b --dry` 接受 `tsc -p` 写出的 tsbuildinfo;失败传播、输出缓冲与进程树终止沿用 run-gates 的调度实现及其测试;`run-gates.spec.ts`、`ci-workflow.spec.ts`、`preview-workflow.spec.ts`、`build-exe-for-python-sdk.spec.ts`、`dev-web.spec.ts` 与 `tests/ci-release-selfhosted.spec.ts` 固定各调用方的命令。
@@ -0,0 +1,46 @@
# Bilingual-pair consistency record for 2026-10-11-lossless-build-speedups.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/process/2026-10-11-lossless-build-speedups.md
/agent-note-build-speed-ups-that-keep-outputs-unchanged:
en: 48682b646b8f993a
zh: 4c26cb4aab5cd08c
/agent-note-build-speed-ups-that-keep-outputs-unchanged/problem:
en: 46551eb7e33c40ca
zh: f8c1fc063fa77062
/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision:
en: 3948d6fc3fabf32e
zh: c3b8dcdfe8168275
/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/measurement-conditions:
en: 2ab844073ec7c336
zh: b25e39f2f80b0d7e
/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/1-only-the-root-manifest-declares-react-type-packages:
en: 1101c94d04f9c7e9
zh: 413b7b15f98a5f62
/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/2-typert-selects-packages-by-manifest-first:
en: 1ae4b65792db0975
zh: a11c0d90842a3b55
/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/3-typert-does-not-expand-aggregate-include:
en: 0c8ab2d412113914
zh: 01ba4f5042422d77
/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/4-typert-parses-package-tsconfigs-lazily:
en: cd6e05c61a7cc495
zh: 480d2d85d78d2d7f
/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/5-the-typert-workspace-root-also-requires-packages:
en: 8b89436df201141c
zh: 2fab82419c8ba704
/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/6-typert-generates-in-a-worker-thread:
en: 0362483a79c07881
zh: d84a2390b49c4012
/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/7-the-typert-transform-hook-filters-by-module-id:
en: b5ad6b5506c150d5
zh: 25f09e4ab74696ad
/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/8-client-tsdown-presets-read-the-manifest-from-the-package-directory:
en: 80c2ddc5fdff0b5f
zh: f5ce309a2e88ff4f
/agent-note-build-speed-ups-that-keep-outputs-unchanged/alternatives-considered:
en: 4372c82ef0c944dd
zh: 5fb70d499baadea7
/agent-note-build-speed-ups-that-keep-outputs-unchanged/consequences:
en: 93a96d3ed398f348
zh: 93b7c7405be981a6
@@ -0,0 +1,136 @@
# Agent Note: Build speed-ups that keep outputs unchanged
Status: implemented
English | [中文](2026-10-11-lossless-build-speedups.zh.md)
## Problem
A cold build carries three fixed costs that do not affect output contents:
- **JSX signatures in the Client test aggregate program.** The Check phase of `tsc -p tsconfig.client.json` takes 59.1 s, more than half of it computing the `@types/react` module specifier for every JSX file that imports React. TypeScript's `computeModuleSpecifiers` visits every symlink location of that declaration file and scans all 491 tsconfig `paths` entries at each location. pnpm creates one `node_modules/@types/react` link for each workspace package that declares `@types/react` itself; 66 manifests declare it, so the Client test aggregate program sees 65 locations. Counters measured 88 JSX files × 2 modules (`index.d.ts`, `jsx-runtime.d.ts`) × 65 locations: 11,457 `tryGetModuleNameFromPaths` calls and 5,623,782 entry scans, 32.6 s in total. A dose-response run at five truncation levels of the visible location count N (N = 1 to 65; Check 26.92 s to 58.40 s) fits Check ≈ 26.9 + 0.494·N s.
- **Typert inside the Host tsdown pass.** The Host tsdown pass takes about 21.6–22.6 s. Typert discovers and generates artifacts synchronously inside the first `writeBundle`; the CPU profile shows it blocking the tsdown main thread for about 12.4 s while every package build waits. Discovery takes 8.06 s: it parses all 362 package tsconfigs referenced by the two face aggregates (about 3.8 s), expands the Host aggregate's 2,064 literal `include` entries on every parse (1.6 s; the analyzer never reads those file names), and walks the public-export surface of 322 registrations (2.7–3.2 s). Only the 28 packages that declare a Typert export produce Host artifacts.
- **Manifest lookup in the Client tsdown pass.** The Client tsdown pass takes about 11.7 s with a saturated main thread (7.1% idle); 34.5% of it (3.95 s) is `workspaceManifest`: every package config runs `globSync('packages/*/*/package.json')` for its package name and parses manifests until the name matches. The module-level cache has no effect because tsdown's native config loader imports every package config through `import-without-cache`, creating a fresh module instance per config. Each glob takes 40–125 ms and runs inside the `neverBundle`/`alwaysBundle` callbacks of `tsdown:deps`, blocking every build.
#5849 merged Host fixture type narrowing and Typert memoization; this note's baseline is the master that follows it.
## Decision
The following eight speed-ups change neither the bytes of `lib/` and `apps/web/dist` outputs nor the set of type diagnostics that repository builds report. The only change in diagnostic ownership comes from item 4: Typert does not validate tsconfigs of packages it does not analyze, and only `tsc -b` reports those errors. [The build split note](2026-10-11-build-artifacts-check-split.md) records the command split and concurrent scheduling.
| # | Decision | Affected stage | Measured median | Outputs and diagnostics | Intended behavior difference |
|---|---|---|---|---|---|
| 1 | Only the root manifest declares React type packages | Client type checking | Test aggregate Check 59.1 → 26.3 s | Program file count and diagnostics unchanged | Package manifests do not list React type packages |
| 2 | Typert selects packages by manifest first | Host tsdown | 22.62 → 19.94 s | Byte-identical | None |
| 3 | Typert does not expand aggregate `include` | Host tsdown | 19.94 → 16.99 s | Byte-identical; registration inventory identical | None |
| 4 | Typert parses package tsconfigs lazily | Host tsdown | 16.99 → 16.25 s (within noise); Typert discovery 5.9–7.1 → 0.36–0.40 s | Byte-identical; registration inventory identical | Only `tsc -b` reports tsconfig errors in unanalyzed packages |
| 5 | The Typert workspace root also requires `packages/` | Host tsdown | Within noise | Byte-identical | Dual-face packages get artifacts in `package` mode |
| 6 | Typert generates in a Worker Thread | Host tsdown | 15.39 → 10.25 s | Byte-identical; failures still fail tsdown | Host tsdown max RSS about +0.6 GiB |
| 7 | The Typert `transform` hook filters by module id | Host tsdown | Within noise; hook calls 1,566 → 195 | Byte-identical | None |
| 8 | Client tsdown presets read the manifest from the package directory | Client tsdown | 12.78 → 8.73 s | Byte-identical; external decisions identical | The presets' first argument is the package directory |
The published `./tsdown` export `typertPlugin()` changes with items 5–7 (`transform` as `{ filter, handler }`, an asynchronous `writeBundle`, and a workspace root that must contain `packages/`); rolldown and tsdown consumers accept both hook forms, and the analyzer already required `<root>/packages`, so no external workspace that worked before stops working.
### Measurement conditions
All numbers come from one shared Linux host (256 vCPU, 4 TB memory, load average 23–111 during runs) with Node v24.18.0, TypeScript 6.0.3, and tsdown 0.22.2 (rolldown 1.1.1).
- **Host tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host`, deleting every `lib/typert.*` before each run; variants run interleaved and sequentially, 3–4 samples per group. Items 2–4 come from the first round (starting from the baseline) and items 5–7 from the second round (starting from the code with items 2–4). Output comparison covers SHA-256 of the 743 top-level files of `packages/*/*/lib`, `apps/*/lib`, and `vendor/*/lib` (140 of them Typert artifacts). Medians of two groups running identical code differ by 0–0.11 s; samples within a group spread by up to 2.5 s.
- **Client test aggregate:** `tsc -p tsconfig.client.json --extendedDiagnostics` with prebuilt references and a fresh tsBuildInfo file; six samples of identical code spread by 3.8 s in Check (about 6%).
- **Client tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client` on a built tree. The item 8 prototype ran three rounds in rotated order with a baseline spread of 1.56 s; the final implementation and the original ran interleaved, three samples each. Output comparison covers every file of `{packages,apps,vendor,native}/**/lib/**`, excluding `*.tsbuildinfo`.
### 1. Only the root manifest declares React type packages
- **Purpose:** Reduce the number N of `@types/react` locations that the Client test aggregate program sees from 65 to 1, removing module-specifier work that grows linearly with N.
- **Change:** The root `package.json` declares `@types/react` `~18.3.1` and `@types/react-dom` `~18.3.0` in `devDependencies`, the ranges every package used before; manifests under `apps/*`, `packages/*/*`, and `vendor/*` declare neither in any dependency section. TypeScript resolves the root `node_modules/@types/react` from every package. Packages still declare runtime `react` and `react-dom` under the [Client dependency declaration rules](../../../../packages/client/AGENTS.md#dependency-declaration); `packages/client/ui-slots`, which references React only through `import type`, declares `react` in `devDependencies`. pnpm `dedupeDirectDeps` stays off. Lockfile changes are confined to the importers section, plus one reordered `papaparse` entry with the same integrity.
- **Regression guard:** [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) runs in `hygiene` and CI. It requires the root manifest's `devDependencies` to declare both packages and rejects either one in the `dependencies`, `devDependencies`, `optionalDependencies`, or `peerDependencies` of any manifest under `apps/*`, `packages/*/*`, or `vendor/*`; the diagnostic names the entry to delete and links Client dependency declaration rule 8. The rule matches only these two exact names; other `@types/*` packages remain DefinitelyTyped type providers. Dependency-section inference does not treat these two packages as type providers for `react`/`react-dom`: when a package lacks a `react` declaration, the verifier reports `react` as missing and `--fix` refuses to repair it until a version range is declared; `--fix` never writes back or deletes the two packages. [`verify-package-dependencies.spec.ts`](../../../../scripts/verify-package-dependencies.spec.ts) covers rejection in each dependency section, a root manifest missing the declarations, the accepted layout, and the type-provider exclusion.
- **Performance:**
| Measurement | Per-package declarations (N = 65) | Root manifest declaration (N = 1) |
|---|---|---|
| Client test aggregate Check | 59.08 s (median of 3) | 26.28 s (median of 5), −56% |
| Client test aggregate wall | 68–71 s | 33.6–34.9 s |
| Client test aggregate user CPU | 79–83 s | 44.5–45.3 s |
| Check sum of the 76 client-only package projects | 34.6 s (median of 2) | 26.5 s (median of 2), −23% |
| Wall of the 76 client-only package projects | 55.7 s (median of 2) | 44.5 s (median of 2) |
- **Why it is lossless:** The 66 package-level links and the root link share one realpath (`.pnpm/@[email protected]`), so the program reads the same declarations; the program keeps 2,613 files, and `tsc -b --force tsconfig.client.json` exits 0 without diagnostics. `devDependencies` are not installed with published packages, so the [published dependency faces](2026-08-26-published-dependency-faces.md) conclusion that independent TypeScript consumers install referenced type packages themselves is unchanged; runtime resolution never reads `@types/*`.
- **Trade-off:** Package manifests do not list their own React type dependency, and every workspace package shares one React types version; a package that needs a different version requires a new Agent Note overturning this item. The check covers only `apps/*`, `packages/*/*`, and `vendor/*`; other pnpm members such as `website`, `benchmarks`, `native/system`, and `python/sdk-runtime` are out of scope, and none of them currently declares React type packages.
### 2. Typert selects packages by manifest first
- **Purpose:** Workspace mode generates artifacts only for packages that declare a Typert export, so it skips the surface walk for every other candidate.
- **Change:** `WorkspaceAnalyzer.discoverPackages(includeManifest?)` and `WorkspaceTypertGenerator.discover(faces?, includeManifest?)` accept an optional `package.json` predicate applied before the surface walk; a rejected package has neither its sources read nor its project config parsed. `WorkspaceTypertGenerator.generateContributors(faces?)` passes `hasTypertExport` (`exports` contains `./typert`, `./client/typert`, or `./remote`). Callers that pass no predicate (the Cordis catalog, `generate()` without packages) behave as before.
- **Performance:** Host tsdown 22.62 s → 19.94 s (medians of 3).
- **Why it is lossless:** The predicate is the filter previously applied after discovery, and a package it rejects never produced artifacts; the 743 output files are byte-identical except the Typert generator's own `lib/index.js`, whose source is this change.
### 3. Typert does not expand aggregate `include`
- **Purpose:** The analyzer reads only an aggregate's compiler options and project references; expanding `include` only fills `fileNames`, which it never uses.
- **Change:** `WorkspaceCaches.aggregate(path)` returns and caches `AggregateConfig { path, options, projectReferences }`. An aggregate that declares its own `references` is parsed with a `ParseConfigHost` whose `readDirectory` returns an empty list; an aggregate without `references` is expanded as usual.
- **Performance:** Host tsdown 19.94 s → 16.99 s (medians of 3).
- **Why it is lossless:** TypeScript reports TS18003 (no inputs) only for a config with neither `files` nor `references`, so an aggregate that declares `references` yields the same diagnostics, options, and references without expansion. The registration inventory dumped after forcing every lazy config (408 registrations, including `fileNames` and options, plus the aggregates' options and references) is byte-identical to the baseline, and the outputs are byte-identical. The tests `reads aggregate options and references without expanding include patterns` and `reports an aggregate without references or inputs` pin both kinds of config.
### 4. Typert parses package tsconfigs lazily
- **Purpose:** Parse only the tsconfigs of packages selected for analysis, not every package that an aggregate references.
- **Change:** `PackageRegistration.config` is a getter over `caches.config(configPath)` that parses on first read. The registration inventory keeps its order, faces, names, roots, and export subpaths.
- **Performance:** Host tsdown 16.99 s → 16.25 s (medians of 3), within the 2.5 s within-group spread. Together, items 2–4 reduce Typert discovery in an isolated benchmark from 5.9–7.1 s to 0.36–0.40 s, tsconfig parses from 362 to 28, surface walks from 322 to 28, and total Typert time from 9.7–11.8 s to 4.4–4.5 s.
- **Why it is lossless:** The inventory comparison is the one in item 3 (byte-identical), and the outputs are byte-identical. The test `reads only the project configs of manifests selected for discovery` pins that only selected configs are parsed.
- **Trade-off (not lossless):** Typert does not parse tsconfigs of packages it does not analyze, so a syntax or configuration error in such a tsconfig does not fail Typert. In the same build, the Host package compilation `tsc -b` that runs before the Host bundle and the Client package compilation `tsc -b` parse every referenced tsconfig and reject those errors; the `PackageRegistration.config` JSDoc and the [Typert generator README](../../../../packages/typert/generator/README.md) state this ownership. A consumer that runs Typert without `tsc -b` does not receive those diagnostics.
### 5. The Typert workspace root also requires `packages/`
- **Purpose:** Correct workspace-root resolution. 24 dual-face packages carry their own `tsconfig.host.json` leaf; searching upward for `tsconfig.host.json` alone took each of them as a root, so workspace mode ran one empty discovery per such package per build, and `package` mode silently produced no artifacts for dual-face packages.
- **Change:** The workspace root is the nearest ancestor of the bundle output that contains both `tsconfig.host.json` and `packages/`.
- **Performance:** Host tsdown 14.82 s → 15.39 s (medians of 3), within noise.
- **Why it is lossless:** The repository root [`tsdown.config.ts`](../../../../tsdown.config.ts) uses only workspace mode, and the 743 output files are byte-identical. The test `resolves the workspace root above a dual-face package leaf` pins the rule.
- **Behavior difference:** A `package`-mode consumer building a dual-face package receives that package's Typert artifacts; repository builds do not use `package` mode.
### 6. Typert generates in a Worker Thread
- **Purpose:** Overlap Typert analysis with bundling on the tsdown main thread instead of blocking every package build inside the first `writeBundle`.
- **Change:** [`src/worker.ts`](../../../../packages/typert/generator/src/worker.ts) is the Worker Thread entry; it runs `generateContributors(faces)` and posts one `artifacts` or `failure` message. In workspace mode, the build's first `buildStart` locates the workspace root from `options.cwd` and starts generation; `writeBundle` is asynchronous, the first finishing bundle claims emission and awaits the result, and later bundles return immediately. An analysis error, a worker crash or early exit, a malformed or undeserializable response, or a worker that cannot be terminated fails the claiming bundle with a `TypertWorkerError` naming the workspace and cause, which fails the whole tsdown pass; the worker is terminated before the outcome settles, and an unclaimed outcome never becomes an unhandled rejection. Built output starts the worker from `./worker.js`; source runs (tests) load `worker.ts` through `tsx/esm/api`. `package` mode still generates on the main thread.
- **Performance:** Host tsdown 15.39 s → 10.25 s (medians of 3). Profile of the final configuration (items 2–7): main thread 10.1 s, first `buildStart` at 4.18 s (tsdown config resolution), `writeBundle` on the main thread only 0.37 s; worker 5.0 s (module load about 0.4 s, discovery 0.28 s, generation 3.88 s including `createProgram` 2.21 s), ending at about 9.3 s. Host tsdown wall is about max(bundling, 4.2 s + Typert).
- **Why it is lossless:** All 54 second-round runs exit 0, and each run's 743 output files match the baseline except the generator's own `lib/index.js`. The test `writes the artifacts in-process generation produces` compares worker and in-process artifacts; in a scratch workspace with one package's `files` broken, tsdown exits 1 within 2.4 s, reporting `TypertWorkerError` with the worker's `TypertAnalysisError`, and writes no artifacts. The `typertPlugin workspace mode` test group covers each failure path.
- **Trade-off:** The worker heap is about 300–420 MiB; the final configuration's median Host tsdown max RSS is 6,296–6,408 MiB against 5,676 MiB at baseline. Under heavy shared-host load the worker can land on the critical path: one sample took 21.91 s with the artifact-owning package build waiting about 14 s for the worker, not reproduced in 10 later samples; at load average about 100, two runs took 17.86 s and 14.3 s. The worker also completes under a 1,024 MiB heap limit, so GC pressure is not the cause; attribution to host contention is unproven.
### 7. The Typert `transform` hook filters by module id
- **Purpose:** Stop every module from crossing from rolldown into the JavaScript `transform` hook only to be rejected by an extension check.
- **Change:** `transform` declares `filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ }`, and its handler still checks the id with the same regular expression.
- **Performance:** Hook calls per Host build fall from 1,566 to 195 (195 TypeScript modules, 4 of them with decorators). Wall medians are 11.07 s (3 samples) and 9.98 s (4 samples) against 10.25 s for item 6, while the identical-code control groups give 11.07 s and 9.87 s; the difference is within noise.
- **Why it is lossless:** The regular expression uses only ASCII constructs and matches the same id set in rolldown's Rust regex engine and in JavaScript; the handler applies the same check, so hosts that ignore filters behave the same. Outputs are byte-identical; the test `filters transform calls to TypeScript module ids` pins the filter.
### 8. Client tsdown presets read the manifest from the package directory
- **Purpose:** Each package config reads only its own `package.json`, removing the glob over every manifest by package name.
- **Change:** The first argument of `clientBundle`, `staticLinked`, and `clientLibrary` in [`packages/client/tsdown.client.ts`](../../../../packages/client/tsdown.client.ts) is the package directory, normally the config file's `import.meta.dirname`; the preset reads `<packageDir>/package.json` once and uses its `name` as the plugin id and diagnostic name; when that manifest is missing, unreadable, or has no `name`, config loading fails with an error naming the path and cause. 82 package configs call these presets. The presets keep no module-level manifest cache; the Node half's production-dependency external list is computed from that manifest on first resolve and cached in that config's closure.
- **Performance:** Client tsdown falls from 12.78 s to 8.73 s (medians of 3), −4.05 s (−31.7%), with disjoint sample ranges; user CPU falls from 26.1 s to 25.2 s and sys from 5.9 s to 4.9 s, so the saving comes mainly from unblocking the main thread rather than from less total CPU. The prototype (one glob building a name index) measured 11.72 s → 7.46 s (−36%). In Host tsdown only 5 `hostPhase` packages reach the lookup; one sample pair measured 11.92 s → 10.71 s, with low confidence.
- **Why it is lossless:** The 11,566 lib files are byte-identical across 11 runs. After the 82 call sites switched to package directories, no resolved package name differs from the name previously written at the call site. An in-process comparison of the external decisions for 82 configs × 3 faces × 1,600 specifiers matches the original implementation exactly; a negative control with one extra external changes 75 decisions. `client-bundle-purity.spec.ts` covers these manifest errors.
- **Behavior difference:** The presets' calling convention changes from a package name to a package directory.
## Alternatives considered
- **Enable pnpm `dedupeDirectDeps` (item 1):** It applies to every direct dependency and changes the link layout of every workspace package, a global side effect.
- **Patch TypeScript (item 1):** An upstream-style patch (precomputed candidate endings and cached normalized `paths` patterns) lowers Check by 31.9 s, comparable to the hoist; on the hoisted tree it adds nothing (references Check 25.76 s against 26.47 s) and must be maintained across every TypeScript upgrade.
- **Alias `react` in tsconfig `paths` (item 1):** tsx, vitest, and vite also read `paths`, so the alias changes their resolution of runtime `react`.
- **Narrow `paths` to each face's reference closure (item 1):** The 328-entry version that keeps wildcard keys checks in 55.73 s against 59.08 s (one sample), within noise; the version without wildcard keys produces 18 diagnostics.
- **Split the Client test aggregate program (item 1):** Every sub-program must inject the 242 files carrying module augmentations to pass, and the visible location count stays at 33–49; splitting into 2, 4, or 8 groups leaves the Check sum nearly flat while user CPU rises by 9%, 30%, and 57%, lowering only wall, and the 4- and 8-group splits each produce 1–2 diagnostics.
- **Cache resolution results and make the discovery queue O(1) (Typert):** After items 2–4 only 28 surface walks remain, about 0.18 s, below noise.
- **Move Typert into a separate build step in artifact builds (Typert):** After item 6 the worker already overlaps bundling, leaving at most about 1 s. Type checks, which need no bundle, run a source-only generation step ([local gate speed-ups](2026-10-12-local-gate-speedups.md)).
- **Filter `transform` by code content (item 7):** rolldown interprets `^` and `\s` with Rust regex semantics, which differ from JavaScript on lone CR, U+2028, and BOM, so a decorated module could be missed.
- **Build a process-wide manifest index on `globalThis` (item 8):** The prototype saves about as much time as item 8, but it is process-global state without an owner, violating the shared-state ownership rule in the [package rules](../../../../packages/AGENTS.md).
- **Disable tsdown's gzip report (item 8):** `--no-report` lowers Client tsdown by 0.95 s, within the 1.56 s baseline spread.
- **Add rolldown filters to DSH `resolveId`/`load` hooks (item 8):** Lowers it by 1.16 s, within noise.
- **Memoize the input-isolation check per module id (item 8):** Lowers it by 0.93 s, within noise; on top of the manifest index it saves only another 0.18 s.
## Consequences
Client type checking spends substantially less CPU and wall time, and the Host and Client tsdown passes take substantially less wall time, without changing runtime resolution, published installation semantics, or any output byte. In the serial build segments (master → branch, one sample each), Host tsdown falls from 22.5 to 11.7 s, Client package compilation from 55.6 to 48.7 s, the Client aggregate check from 68.2 to 35.9 s, and Client tsdown from 13.9 to 10.5 s; the serial segments sum to 308.5 s on master and 254.9 s on the branch (see [the build split note](2026-10-11-build-artifacts-check-split.md)). Host tsdown lies on the critical path of both `build` and `build:artifacts`, Client tsdown on that of `build:artifacts`, and the Client aggregate check is the last step of the `build` critical path.
Costs: every workspace package shares one React types version; Typert does not validate tsconfigs of packages it does not analyze, and `tsc -b` owns those errors; Host tsdown max RSS grows by about 0.6 GiB, and under heavy shared-host load the worker can become Host tsdown's critical path.
Reevaluation conditions: remeasure item 1 when migrating to TypeScript 7, and reconsider per-package declarations if the location count no longer affects Check time. tsdown's own config loading still takes 1.8–2.3 s per Client build (one uncached module instance per config) and needs an upstream change. The first `buildStart` arrives about 4.2 s into the Host build; starting the worker at plugin construction with an explicit root would start Typert earlier.
Verification: the Typert generator's `tsdown-plugin.spec.ts`, `tsdown-plugin-worker.spec.ts`, and `type-model.spec.ts` pin the behavior of items 2–7; the generator tests report 209 passed and 28 skipped, and `tsc -b tsconfig.host.json` exits 0. The repository coverage gate excludes `packages/typert/*/src/**`; under a scratch coverage config, `tsdown-plugin.ts` reaches 100% statements, branches, functions, and lines. Five negative controls (the old root rule, eager package configs, an expanded aggregate, no emission claim, a skipped worker termination) each fail the test that guards them; the real tsdown failure path exits 1 within 2.4 s and reports `TypertWorkerError`. Item 1: `verify-package-dependencies` exits 0 (74 packages), its spec passes 47/47, `verify-client-packages` exits 0, `tsc -b tsconfig.client.json` reports no diagnostics, and the Client aggregate Check takes 26.48 s. Item 8: all 72 focused specs pass.
@@ -0,0 +1,136 @@
# Agent Note: 不改变产物的构建提速
Status: implemented
[English](2026-10-11-lossless-build-speedups.md) | 中文
## 问题
冷启动构建中有三处与产物内容无关的固定开销:
- **Client 测试聚合程序的 JSX 签名。** `tsc -p tsconfig.client.json` 的 Check 阶段为 59.1 s,其中一半以上用于为每个导入 React 的 JSX 文件计算 `@types/react` 的 module specifier。TypeScript 的 `computeModuleSpecifiers` 遍历该声明文件的每个符号链接位置,并在每个位置扫描全部 491 条 tsconfig `paths`。pnpm 为每个自行声明 `@types/react` 的 workspace 包创建一个 `node_modules/@types/react` 链接;66 个 manifest 声明了它,Client 测试聚合程序因此看到 65 个位置。计数器测得 88 个 JSX 文件 × 2 个模块(`index.d.ts`、`jsx-runtime.d.ts`)× 65 个位置,共 11,457 次 `tryGetModuleNameFromPaths` 调用、5,623,782 次条目扫描,合计 32.6 s。按可见位置数 N 截断的剂量实验在五个截断级别(N 从 1 到 65,Check 从 26.92 s 到 58.40 s)上拟合出 Check ≈ 26.9 + 0.494·N s。
- **Host tsdown 阶段中的 Typert。** Host tsdown 阶段约 21.6–22.6 s。Typert 在第一个 `writeBundle` 中同步发现并生成产物,CPU profile 显示它阻塞 tsdown 主线程约 12.4 s,期间所有包构建都在等待。发现阶段占 8.06 s:它解析两个 face 聚合配置所引用的 362 个包 tsconfig(约 3.8 s),每次解析 Host 聚合配置都展开 2,064 条字面 `include`(1.6 s,分析器从不读取这些文件名),并对 322 个注册项做公开导出表面遍历(2.7–3.2 s);最终只有 28 个声明 Typert 导出的包产生 Host 产物。
- **Client tsdown 阶段的 manifest 查找。** Client tsdown 阶段约 11.7 s,主线程饱和(空闲仅 7.1%),其中 34.5%(3.95 s)在 `workspaceManifest`:每个包配置按包名 `globSync('packages/*/*/package.json')` 并逐个解析 manifest,直到名字匹配。模块级缓存无效,因为 tsdown 的 native 配置加载器通过 `import-without-cache` 为每个包配置创建新的模块实例;每次 glob 耗时 40–125 ms,且发生在 `tsdown:deps` 的 `neverBundle`/`alwaysBundle` 回调中,阻塞全部构建。
#5849 已合并 Host fixture 类型收窄与 Typert memoization;本 Note 的基线是其后的 master。
## 决策
以下八项提速不改变 `lib/`、`apps/web/dist` 的产物字节,也不改变仓库构建报告的类型诊断集合。唯一的诊断归属变化来自第 4 项:Typert 不校验它不分析的包 tsconfig,这些错误只由 `tsc -b` 报告。命令拆分与并发调度由[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md)记录。
| # | 决策 | 受影响阶段 | 实测中位数 | 产物与诊断 | 有意的行为差异 |
|---|---|---|---|---|---|
| 1 | React 类型包只由根 manifest 声明 | Client 类型检查 | 测试聚合 Check 59.1 → 26.3 s | 程序文件数与诊断不变 | 包 manifest 不列出 React 类型包 |
| 2 | Typert 先按 manifest 选择包 | Host tsdown | 22.62 → 19.94 s | 逐字节一致 | 无 |
| 3 | Typert 不展开聚合配置的 `include` | Host tsdown | 19.94 → 16.99 s | 逐字节一致;注册清单一致 | 无 |
| 4 | Typert 惰性解析包 tsconfig | Host tsdown | 16.99 → 16.25 s(噪声内);Typert 发现 5.9–7.1 → 0.36–0.40 s | 逐字节一致;注册清单一致 | 未分析包的 tsconfig 错误只由 `tsc -b` 报告 |
| 5 | Typert 工作区根目录同时要求 `packages/` | Host tsdown | 噪声内 | 逐字节一致 | `package` 模式下双 face 包获得产物 |
| 6 | Typert 在 Worker Thread 中生成 | Host tsdown | 15.39 → 10.25 s | 逐字节一致;失败仍使 tsdown 失败 | Host tsdown 最大 RSS 约 +0.6 GiB |
| 7 | Typert `transform` 钩子按模块 id 过滤 | Host tsdown | 噪声内;钩子调用 1,566 → 195 | 逐字节一致 | 无 |
| 8 | Client tsdown 预设按包目录读取 manifest | Client tsdown | 12.78 → 8.73 s | 逐字节一致;external 判定一致 | 预设第一个参数是包目录 |
第 5–7 项改变了发布的 `./tsdown` 导出 `typertPlugin()`(`transform` 为 `{ filter, handler }`、`writeBundle` 为异步、工作区根目录必须包含 `packages/`);rolldown 与 tsdown 的使用方接受两种钩子形式,分析器本来就要求 `<root>/packages`,因此此前可用的外部工作区都不会失效。
### 测量条件
所有数字取自同一台共享 Linux 主机(256 vCPU、4 TB 内存,运行期间 load average 23–111),Node v24.18.0、TypeScript 6.0.3、tsdown 0.22.2(rolldown 1.1.1)。
- **Host tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host`,每次运行前删除全部 `lib/typert.*`;各变体交替顺序运行,每组 3–4 个样本。第 2–4 项取自第一轮测量(起点为基线),第 5–7 项取自第二轮测量(起点为第 2–4 项合入后的代码)。产物比较覆盖 `packages/*/*/lib`、`apps/*/lib`、`vendor/*/lib` 顶层的 743 个文件(含 140 个 Typert 产物)的 SHA-256。相同代码的两组中位数相差 0–0.11 s,组内样本跨度最大 2.5 s。
- **Client 测试聚合:** references 预先构建后运行 `tsc -p tsconfig.client.json --extendedDiagnostics`,使用全新的 tsBuildInfo 文件;相同代码 6 个样本的 Check 跨度为 3.8 s(约 6%)。
- **Client tsdown:** 已构建的树上运行 `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client`。第 8 项的原型 3 轮轮换顺序,基线跨度 1.56 s;最终实现与原实现交替运行,各 3 个样本。产物比较覆盖 `{packages,apps,vendor,native}/**/lib/**`(排除 `*.tsbuildinfo`)的全部文件。
### 1. React 类型包只由根 manifest 声明
- **目的:** 把 Client 测试聚合程序看到的 `@types/react` 位置数 N 从 65 降到 1,消除随 N 线性增长的 module specifier 计算。
- **改动:** 根 `package.json` 的 `devDependencies` 声明 `@types/react` `~18.3.1` 与 `@types/react-dom` `~18.3.0`,沿用各包原先统一的版本范围;`apps/*`、`packages/*/*` 与 `vendor/*` 的 manifest 在任何依赖区段都不声明它们。TypeScript 从任一包向上查找时都解析到根 `node_modules/@types/react`。runtime `react` 与 `react-dom` 仍按 [Client 依赖声明规则](../../../../packages/client/AGENTS.md#dependency-declaration)由各包声明;只通过 `import type` 引用 React 的 `packages/client/ui-slots` 在 `devDependencies` 中声明 `react`。pnpm `dedupeDirectDeps` 保持关闭。lockfile 的变化集中在 importers 区段(另有一条 integrity 相同的 `papaparse` 条目重排)。
- **防回退:** [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 在 `hygiene` 与 CI 中要求根 manifest 的 `devDependencies` 声明这两个包,并拒绝 `apps/*`、`packages/*/*`、`vendor/*` 中任一 manifest 在 `dependencies`、`devDependencies`、`optionalDependencies` 或 `peerDependencies` 中声明它们;诊断指明要删除的条目,并链接 Client 依赖声明规则第 8 条。规则只匹配这两个精确包名,其他 `@types/*` 包仍按 DefinitelyTyped 类型提供方处理。依赖区段推导不把这两个包视为 `react`/`react-dom` 的类型提供方:包缺少 `react` 声明时,验证器报告 `react` 缺失,`--fix` 拒绝修复并要求先声明版本范围;`--fix` 也不写回或删除这两个包。[`verify-package-dependencies.spec.ts`](../../../../scripts/verify-package-dependencies.spec.ts) 覆盖各依赖区段的拒绝、根 manifest 缺失声明、被接受的布局,以及类型提供方排除。
- **性能:**
| 测量项 | 各包自声明(N = 65) | 根 manifest 声明(N = 1) |
|---|---|---|
| Client 测试聚合 Check | 59.08 s(3 个样本的中位数) | 26.28 s(5 个样本的中位数),−56% |
| Client 测试聚合 wall | 68–71 s | 33.6–34.9 s |
| Client 测试聚合 user CPU | 79–83 s | 44.5–45.3 s |
| 76 个 client-only 包项目的 Check 总和 | 34.6 s(2 个样本的中位数) | 26.5 s(2 个样本的中位数),−23% |
| 76 个 client-only 包项目的 wall | 55.7 s(2 个样本的中位数) | 44.5 s(2 个样本的中位数) |
- **无损依据:** 66 个包级链接与根链接指向同一个 realpath(`.pnpm/@[email protected]`),因此程序读取的声明内容相同;程序文件数保持 2,613,`tsc -b --force tsconfig.client.json` 以 0 退出且无诊断。`devDependencies` 不随已发布的包安装,[发布依赖面](2026-08-26-published-dependency-faces.zh.md)中独立 TypeScript 消费者自行安装所引用类型包的结论不变;runtime 解析不读取 `@types/*`。
- **取舍:** 包 manifest 不列出自己的 React 类型依赖,所有 workspace 包共用同一个 React 类型版本;某个包需要不同版本时,需要新的 Agent Note 推翻本项。检查只覆盖 `apps/*`、`packages/*/*` 与 `vendor/*`;`website`、`benchmarks`、`native/system` 与 `python/sdk-runtime` 等其他 pnpm 成员不在范围内,目前它们都不声明 React 类型包。
### 2. Typert 先按 manifest 选择包
- **目的:** workspace 模式只为声明 Typert 导出的包生成产物,避免对其余候选包做表面遍历。
- **改动:** `WorkspaceAnalyzer.discoverPackages(includeManifest?)` 与 `WorkspaceTypertGenerator.discover(faces?, includeManifest?)` 接受可选的 `package.json` 谓词,在表面遍历之前应用;被拒绝的包既不读取源码,也不解析项目配置。`WorkspaceTypertGenerator.generateContributors(faces?)` 以 `hasTypertExport`(`exports` 含 `./typert`、`./client/typert` 或 `./remote`)为谓词。不传谓词的调用方(Cordis 目录、不指定包的 `generate()`)行为不变。
- **性能:** Host tsdown 22.62 s → 19.94 s(各 3 个样本的中位数)。
- **无损依据:** 谓词就是原先在发现之后应用的同一过滤条件,被它拒绝的包本来就不产生产物;743 个产物文件逐字节一致(Typert generator 自身的 `lib/index.js` 除外,其源码即本项改动)。
### 3. Typert 不展开聚合配置的 `include`
- **目的:** 分析器只读取聚合配置的编译选项与 project references,展开 `include` 只为填充从不使用的 `fileNames`。
- **改动:** `WorkspaceCaches.aggregate(path)` 返回并缓存 `AggregateConfig { path, options, projectReferences }`。解析自身声明 `references` 的聚合配置时,使用 `readDirectory` 返回空列表的 `ParseConfigHost`;不声明 `references` 的聚合配置照常展开。
- **性能:** Host tsdown 19.94 s → 16.99 s(各 3 个样本的中位数)。
- **无损依据:** TypeScript 只对既无 `files` 也无 `references` 的配置报告 TS18003(无输入),因此声明 `references` 的聚合配置在不展开时得到相同的诊断、选项与 references。强制读取每个惰性配置后导出的注册清单(408 个注册项,含 `fileNames` 与选项,以及聚合配置的选项和 references)与基线逐字节一致;产物逐字节一致。测试 `reads aggregate options and references without expanding include patterns` 与 `reports an aggregate without references or inputs` 固定这两种配置的行为。
### 4. Typert 惰性解析包 tsconfig
- **目的:** 只解析被选中分析的包的 tsconfig,而不是聚合配置引用的全部包。
- **改动:** `PackageRegistration.config` 是 `caches.config(configPath)` 的 getter,首次读取时解析。注册清单的顺序、face、包名、根目录与导出子路径不变。
- **性能:** Host tsdown 16.99 s → 16.25 s(各 3 个样本的中位数),在 2.5 s 的组内跨度以内。第 2–4 项合计在隔离基准中把 Typert 发现从 5.9–7.1 s 降到 0.36–0.40 s,tsconfig 解析从 362 次降到 28 次,表面遍历从 322 次降到 28 次,Typert 总耗时从 9.7–11.8 s 降到 4.4–4.5 s。
- **无损依据:** 注册清单比较与第 3 项相同(逐字节一致),产物逐字节一致。测试 `reads only the project configs of manifests selected for discovery` 固定只解析所选包的配置。
- **取舍(非无损):** Typert 不解析它不分析的包的 tsconfig,因此这类 tsconfig 的语法或配置错误不会使 Typert 失败。同一构建中,先于 Host bundle 运行的 Host 包编译 `tsc -b` 与 Client 包编译 `tsc -b` 解析每个被引用的 tsconfig,并拒绝其中的错误;`PackageRegistration.config` 的 JSDoc 与 [Typert generator README](../../../../packages/typert/generator/README.zh.md) 写明这一归属。单独调用 Typert 而不运行 `tsc -b` 的使用方不会得到这些诊断。
### 5. Typert 工作区根目录同时要求 `packages/`
- **目的:** 修正工作区根目录的判定。24 个双 face 包带有自己的 `tsconfig.host.json` 叶子配置;只按 `tsconfig.host.json` 向上查找时,每个这样的包都被当作根目录,workspace 模式每次构建为它们各做一次空发现,`package` 模式下双 face 包得不到产物且没有报错。
- **改动:** 工作区根目录是打包输出最近的、同时包含 `tsconfig.host.json` 与 `packages/` 的祖先目录。
- **性能:** Host tsdown 14.82 s → 15.39 s(各 3 个样本的中位数),在噪声内。
- **无损依据:** 仓库根 [`tsdown.config.ts`](../../../../tsdown.config.ts) 只使用 workspace 模式,743 个产物文件逐字节一致。测试 `resolves the workspace root above a dual-face package leaf` 固定该判定。
- **行为差异:** `package` 模式的使用方构建双 face 包时得到该包的 Typert 产物;仓库构建不使用 `package` 模式。
### 6. Typert 在 Worker Thread 中生成
- **目的:** 让 Typert 分析与 tsdown 主线程上的打包重叠,而不是在第一个 `writeBundle` 中阻塞全部包构建。
- **改动:** [`src/worker.ts`](../../../../packages/typert/generator/src/worker.ts) 是 Worker Thread 入口,运行 `generateContributors(faces)` 并回传一条 `artifacts` 或 `failure` 消息。workspace 模式下,本次构建的第一个 `buildStart` 以 `options.cwd` 定位工作区根目录并启动生成;`writeBundle` 是异步的,第一个完成的打包认领写入并等待结果,之后的打包立即返回。分析错误、worker 崩溃或提前退出、格式错误或无法反序列化的响应、worker 无法终止,都以指明工作区与原因的 `TypertWorkerError` 使认领写入的打包失败,从而使整个 tsdown 阶段失败;结果敲定前 worker 已终止,未被认领的结果不会成为未处理的 rejection。构建产物从 `./worker.js` 启动 worker;源码运行(测试)通过 `tsx/esm/api` 加载 `worker.ts`。`package` 模式仍在主线程中生成。
- **性能:** Host tsdown 15.39 s → 10.25 s(各 3 个样本的中位数)。最终配置(第 2–7 项)的 profile:主线程 10.1 s,第一个 `buildStart` 出现在 4.18 s(tsdown 配置解析),主线程上的 `writeBundle` 只占 0.37 s;worker 5.0 s(模块加载约 0.4 s、发现 0.28 s、生成 3.88 s,其中 `createProgram` 2.21 s),约在 9.3 s 结束。Host tsdown 的 wall 约为 max(打包, 4.2 s + Typert)。
- **无损依据:** 第二轮的 54 次运行全部以 0 退出,每次的 743 个产物文件均与基线一致(generator 自身的 `lib/index.js` 除外)。测试 `writes the artifacts in-process generation produces` 比较 worker 与进程内生成的产物;在临时工作区中把一个包的 `files` 改坏后,tsdown 在 2.4 s 内以 1 退出,报告 `TypertWorkerError` 及 worker 内的 `TypertAnalysisError`,不写入任何产物。各失败路径由 `typertPlugin workspace mode` 测试组覆盖。
- **取舍:** worker 堆约 300–420 MiB;最终配置的 Host tsdown 最大 RSS 中位数为 6,296–6,408 MiB,基线为 5,676 MiB。共享主机高负载时 worker 可能落在关键路径上:一次样本为 21.91 s,拥有产物的包构建等待 worker 约 14 s,其后 10 个样本未复现;load average 约 100 时两次运行为 17.86 s 与 14.3 s。worker 在 1,024 MiB 堆上限下也能完成,因此不是 GC 压力;归因于主机争用尚未证实。
### 7. Typert `transform` 钩子按模块 id 过滤
- **目的:** 避免每个模块都从 rolldown 进入 JavaScript `transform` 钩子,只为被扩展名检查拒绝。
- **改动:** `transform` 声明 `filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ }`,处理函数仍以同一正则检查 id。
- **性能:** 每次 Host 构建的钩子调用从 1,566 次降到 195 次(195 个 TypeScript 模块,其中 4 个含装饰器)。wall 中位数为 11.07 s(3 个样本)与 9.98 s(4 个样本),第 6 项为 10.25 s,相同代码的对照组为 11.07 s 与 9.87 s,差异在噪声内。
- **无损依据:** 该正则只含 ASCII 构造,在 rolldown 的 Rust 正则与 JavaScript 中匹配相同的 id 集合;处理函数应用同一检查,因此不读取过滤器的宿主行为相同。产物逐字节一致;测试 `filters transform calls to TypeScript module ids` 固定过滤器。
### 8. Client tsdown 预设按包目录读取 manifest
- **目的:** 每个包配置只读取自己的 `package.json`,消除按包名遍历全部 manifest 的 glob。
- **改动:** [`packages/client/tsdown.client.ts`](../../../../packages/client/tsdown.client.ts) 的 `clientBundle`、`staticLinked` 与 `clientLibrary` 的第一个参数是包目录(通常为配置文件的 `import.meta.dirname`),预设读取一次 `<packageDir>/package.json`,以其中的 `name` 作为插件 id 与诊断名称;该 manifest 缺失、不可读或缺少 `name` 时,配置加载以指明路径与原因的错误失败。82 个包配置调用这些预设。预设不保留模块级 manifest 缓存;Node 半边的生产依赖 external 列表在首次 resolve 时由该 manifest 计算,并缓存在该配置的闭包中。
- **性能:** Client tsdown 从 12.78 s 降到 8.73 s(各 3 个样本的中位数),−4.05 s(−31.7%),两组样本区间不重叠;user CPU 从 26.1 s 降到 25.2 s,sys 从 5.9 s 降到 4.9 s,因此收益主要来自解除主线程阻塞,而不是减少 CPU 总量。原型(一次 glob 建立按包名索引)为 11.72 s → 7.46 s(−36%)。Host tsdown 中只有 5 个 `hostPhase` 包经过该查找,一对样本为 11.92 s → 10.71 s,置信度低。
- **无损依据:** 11 次运行的 11,566 个 lib 文件逐字节一致。82 个调用处改为传入包目录后,没有一个解析出的包名与原先手写的包名不同。进程内比较 82 个配置 × 3 个 face × 1,600 个 specifier 的 external 判定,结果与原实现完全一致;负对照(多加一个 external)使 75 个判定不同。`client-bundle-purity.spec.ts` 覆盖这些 manifest 错误。
- **行为差异:** 预设的调用约定从包名改为包目录。
## 考虑过的替代方案
- **开启 pnpm `dedupeDirectDeps`(第 1 项):** 作用于所有直接依赖,改变每个 workspace 包的链接布局,副作用是全局的。
- **给 TypeScript 打补丁(第 1 项):** 上游式补丁(预先计算候选扩展名并缓存规范化的 `paths` 模式)使 Check 降低 31.9 s,与提升相当;在已提升的树上不再带来收益(refs Check 25.76 s 对 26.47 s),且需随每次 TypeScript 升级维护。
- **在 tsconfig `paths` 中为 `react` 加别名(第 1 项):** tsx、vitest 与 vite 也读取 `paths`,别名会改变它们对 runtime `react` 的解析。
- **把 `paths` 收窄为各 face 的 reference closure(第 1 项):** 保留通配键的 328 条版本 Check 为 55.73 s 对 59.08 s(1 个样本),在噪声内;去掉通配键的版本产生 18 个诊断。
- **拆分 Client 测试聚合程序(第 1 项):** 每个子程序都必须注入 242 个含模块增强的文件才能通过检查,可见位置数仍为 33–49;拆成 2、4、8 组时 Check 总和基本不变,user CPU 分别增加 9%、30%、57%,只降低 wall,且 4 组与 8 组各出现 1–2 个诊断。
- **缓存解析结果并把发现队列改为 O(1)(Typert):** 第 2–4 项之后只剩 28 次表面遍历,约 0.18 s,低于噪声。
- **在产物构建中把 Typert 移到独立构建步骤(Typert):** 第 6 项之后 worker 已与打包重叠,剩余收益不超过约 1 s。不需要 bundle 的类型检查运行只读源码的生成步骤([本地门禁提速](2026-10-12-local-gate-speedups.zh.md))。
- **按代码内容过滤 `transform`(第 7 项):** rolldown 以 Rust 正则语义解释 `^` 与 `\s`,在孤立 CR、U+2028 与 BOM 上与 JavaScript 不同,可能漏掉含装饰器的模块。
- **在 `globalThis` 上建立进程级 manifest 索引(第 8 项):** 原型收益与第 8 项相当,但它是没有 owner 的进程全局状态,违反[包规则](../../../../packages/AGENTS.md)中的共享状态归属要求。
- **关闭 tsdown 的 gzip 报告(第 8 项):** `--no-report` 使 Client tsdown 降低 0.95 s,在 1.56 s 的基线跨度内。
- **为 DSH 的 `resolveId`/`load` 钩子加 rolldown 过滤器(第 8 项):** 降低 1.16 s,在噪声内。
- **按模块 id 缓存输入隔离检查(第 8 项):** 降低 0.93 s,在噪声内;叠加在 manifest 索引之上只再降低 0.18 s。
## 后果
Client 类型检查的 CPU 与 wall 时间、Host tsdown 与 Client tsdown 的 wall 时间都明显下降,且不改变 runtime 解析、发布安装语义或任何产物字节。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;串行各段合计在 master 上为 308.5 s,在本分支上为 254.9 s(见[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md))。Host tsdown 位于 `build` 与 `build:artifacts` 的关键路径上,Client tsdown 位于 `build:artifacts` 的关键路径上,Client 聚合检查是 `build` 关键路径的最后一步。
代价:所有 workspace 包共用一个 React 类型版本;Typert 不校验它不分析的包 tsconfig,这些错误由 `tsc -b` 负责;Host tsdown 的最大 RSS 约增加 0.6 GiB,共享主机高负载时 worker 可能成为 Host tsdown 的关键路径。
重新评估条件:迁移到 TypeScript 7 时重新测量第 1 项,若位置数不再影响 Check 时间,可以重新评估包级声明。tsdown 的配置加载本身每次 Client 构建仍占 1.8–2.3 s(每个配置一个无缓存的模块实例),需要上游改动。第一个 `buildStart` 在 Host 构建开始约 4.2 s 后才出现;在插件构造时以显式根目录启动 worker 可以进一步提前 Typert。
验证:Typert generator 的 `tsdown-plugin.spec.ts`、`tsdown-plugin-worker.spec.ts` 与 `type-model.spec.ts` 固定第 2–7 项的行为;generator 测试 209 个通过、28 个跳过,`tsc -b tsconfig.host.json` 以 0 退出。仓库覆盖率 gate 不包含 `packages/typert/*/src/**`,在临时覆盖率配置下 `tsdown-plugin.ts` 的 statements、branches、functions 与 lines 均为 100%。五个负对照(旧根目录规则、立即解析包配置、展开聚合配置、不认领写入、跳过 worker 终止)各自使守护它的测试失败;真实 tsdown 失败路径在 2.4 s 内以 1 退出并报告 `TypertWorkerError`。第 1 项:`verify-package-dependencies` 以 0 退出(74 个包),其 spec 47/47 通过,`verify-client-packages` 以 0 退出,`tsc -b tsconfig.client.json` 无诊断,Client 聚合 Check 为 26.48 s。第 8 项:72 个相关 spec 全部通过。
@@ -0,0 +1,49 @@
# Bilingual-pair consistency record for 2026-10-12-local-gate-speedups.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/process/2026-10-12-local-gate-speedups.md
/agent-note-local-gate-hook-and-test-speed-ups:
en: d8edb2c26730e9af
zh: fb69bf2e7b602d4c
/agent-note-local-gate-hook-and-test-speed-ups/problem:
en: 1e2163ec378c44ff
zh: d93cb5970896fccb
/agent-note-local-gate-hook-and-test-speed-ups/decision:
en: 9984b4ad646bfaf2
zh: 753287ae67e29605
/agent-note-local-gate-hook-and-test-speed-ups/decision/measurement-conditions:
en: 7430353afba96352
zh: 6d2a09619a67ecba
/agent-note-local-gate-hook-and-test-speed-ups/decision/1-type-checks-generate-typert-declarations-without-bundling:
en: 4dc48e24a3323b7d
zh: 7c88325160a41d12
/agent-note-local-gate-hook-and-test-speed-ups/decision/2-the-pre-commit-hook-does-not-regenerate-third-party-notices:
en: e2e344f184621172
zh: 97d8785aaf9105bf
/agent-note-local-gate-hook-and-test-speed-ups/decision/3-local-default-worker-budget:
en: e9649c40d67e26e0
zh: c99f22a41c4fc4af
/agent-note-local-gate-hook-and-test-speed-ups/decision/4-repository-reference-candidates-resolve-against-one-object-listing:
en: 2c945250337c88cf
zh: d4bfa34cc858782b
/agent-note-local-gate-hook-and-test-speed-ups/decision/5-documentation-gates-parse-each-document-once:
en: c22d38399a2bb87f
zh: 9ae97e5a6e3fbf48
/agent-note-local-gate-hook-and-test-speed-ups/decision/6-repository-file-walks-list-each-directory-once:
en: 2f0d35b0a688c938
zh: 7184fd260c066a01
/agent-note-local-gate-hook-and-test-speed-ups/decision/7-cli-help-reads-run-concurrently-doc-graph-probes-are-memoized:
en: 965503ba8a31c039
zh: fe02a394a677d018
/agent-note-local-gate-hook-and-test-speed-ups/decision/8-type-model-spec-ts-runs-against-an-in-memory-workspace:
en: d519ec362903aa2f
zh: f49ee6547cb954f6
/agent-note-local-gate-hook-and-test-speed-ups/decision/repository-wide-comparison:
en: 7c9388dc859e0f93
zh: 5df1fc58eb7a46e8
/agent-note-local-gate-hook-and-test-speed-ups/alternatives-considered:
en: 763d94734b8967cf
zh: abe980d486e1d7da
/agent-note-local-gate-hook-and-test-speed-ups/consequences:
en: ca00b04567d09408
zh: 97011043c60f4bc9
@@ -0,0 +1,158 @@
# Agent Note: Local gate, hook, and test speed-ups
Status: implemented
English | [中文](2026-10-12-local-gate-speedups.zh.md)
## Problem
On the baseline, master at the merge of #6013, local runs of the Git hooks, `pnpm run test`, `test:docs`, and `doc-sync` spend most of their wall time on work that their results do not depend on:
- **Typert declarations from the bundler.** `typecheck`, the command the pre-push hook runs, executes `build:lib:host` (Host `tsc -b tsconfig.host.json`, the Host tsdown pass, and the Desktop bundle) before `tsc -b tsconfig.client.json`. The prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and the CI `typert-contracts` gate runs Host package compilation and then the Host tsdown pass (`compile-referenced-projects.ts host-libraries`). Type checks read only package declarations and Typert declarations, yet `typecheck` with nothing to recheck takes 34.1/35.0 s and the prerequisite takes 52.2/29.6 s.
- **Third-party notices on every commit.** The pre-commit hook regenerates `THIRD_PARTY_NOTICES.md` whenever a staged file is a generator input. To find browser-side third-party imports, the generator test-bundles 75 Client packages and runs one Vite build, so every package source file is an input; on a commit that stages one file under a package `src/` subdirectory, the hooks take 16.5/17.8 s, of which the notices job takes 12.4/13.4 s.
- **Host CPUs as the local worker count.** `os.availableParallelism()` reports the host's CPUs (256 on the measurement host) rather than the container's CPU quota (64). With that default, `pnpm run test` starts 255 Vitest forks, peaks at 55.3 GiB RSS, and fails 12 additional files (14 timeouts) intermittently, while `doc-sync`, `test:docs`, `hygiene`, and `check:all` run at most 4 gates concurrently.
- **One Git prefix lookup per candidate.** `verify-repository-references` resolves each of about 26,000 abbreviated hexadecimal candidates with a Git prefix lookup through `git cat-file --batch-check`; the lookups take 135.7 s of the gate's 166.8 s.
- **Repeated Markdown parsing.** markdown links parses each anchor target twice more and probes target existence without a cache; translation pairing globs four times and parses each document again after link normalization; the package README model-experience and limitations checks each parse every README three times; type equivalence parses all 1,835 documents; Session format references parses each pair four times.
- **Repeated directory reads.** `uniqueRepoFiles`, the walker behind markdown wrap, doc refs, and package paths, reads each directory once per pattern and `**` level and resolves every matched file with `realpath`.
- **Serial help launches and repeated compiler probes.** `gen-cli-help` launches its 12 top-level source-mode `dsh … --help` processes one after another, each waiting mostly on profile boot I/O (49.9/53.9 s); `gen-doc-graphs` builds several compiler hosts and programs that repeat the same `ts.sys` file-system probes (48.7/44.7 s).
- **A test file bound by file-system probes.** Each of the 61 cases of the Typert generator's `type-model.spec.ts` copies its fixture to disk and runs a full analysis whose four TypeScript programs each load the DOM library, `@types/node`, and zod v4, about 22,000 stat calls per analysis. The file takes 316.3/329.1 s and is the long tail of `pnpm run test`.
## Decision
The eight decisions below shorten those runs. Each item states the evidence that its outputs and diagnostics stay the same, or the intended difference it accepts.
| # | Decision | Affected commands | Measured wall | Outputs and diagnostics | Intended behavior difference |
|---|---|---|---|---|---|
| 1 | Type checks generate Typert declarations without bundling | `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, CI `typert-contracts` | `typecheck` 34.1/35.0 → 21.9/20.0 s; prerequisite 52.2/29.6 → 12.5/11.3 s | 140 Typert files byte-identical; the same programs are checked | No runnable bundle and no client build record |
| 2 | The pre-commit hook does not regenerate third-party notices | `pre-commit` | 16.5/17.8 → 4.3/4.5 s | Freshness asserted in the test lane | Stale notices fail in the test lane, not at commit |
| 3 | Local default worker budget | run-gates aggregates, `build`, `typecheck`, `pnpm run test`, Oxlint, publint | `test` at 255 workers: 243.7 s, 55.3 GiB; `test:docs` 74.7 → 50.9 s | Same checks | Local defaults ignore CPUs above 12 or 48 |
| 4 | Repository-reference candidates resolve against one object listing | `verify-repository-references` | 166.8 → 15.7 s | 0 classification differences over 27,050 candidates | None |
| 5 | Documentation gates parse each document once | markdown links, translation pairing, README model experience and limitations, type equivalence, Session format references | markdown links 53.4/51.6 → 25.9/25.1 s; type equivalence 23.0/22.5 → 6.0/5.5 s | Byte-identical gate output | None |
| 6 | Repository file walks list each directory once | markdown wrap, doc refs, package paths | doc refs 8.8/7.0 → 3.2/3.4 s | Identical file lists | doc refs does not read nested `lib` directories |
| 7 | CLI help reads run concurrently; doc-graph probes are memoized | `verify-cli-help`, `verify-doc-graphs` | 49.9/53.9 → 6.9/7.2 s; 48.7/44.7 → 32.7/34.2 s | Byte-identical pages | CLI help uses about 3.6 GB at peak |
| 8 | `type-model.spec.ts` runs against an in-memory workspace | `pnpm run test` | 316.3/329.1 → 14.65/15.04 s | Same 61 cases and assertions; one snapshot line differs | Stubs replace zod, `@types/node`, and the DOM library outside the Emitter case |
### Measurement conditions
All numbers are wall times from one shared Linux container with a 64-CPU quota on a host that reports 256 CPUs, at a one-minute load average of 20–150, so samples carry noise. The working tree lives on a network filesystem, where each file-system probe costs more than on a local disk. `a/b` lists two samples, and `x → y` compares the baseline with this implementation.
### 1. Type checks generate Typert declarations without bundling
- **Purpose:** Give `typecheck` and the prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and the CI `typert-contracts` gate the declarations they read, package declarations from `tsc` and Typert declarations, without the Host tsdown pass or the Desktop bundle.
- **Change:** `typecheck` is `tsx scripts/build.ts --scope typecheck`, which runs the steps below through the run-gates scheduler ([`scripts/build.ts`](../../../../scripts/build.ts)). The scope builds no native addon, tsdown bundle, Desktop bundle, or Web output, writes no client build record, and leaves runnable `lib/` output as the last `build:artifacts` left it; run `build:artifacts` before launching or testing built output. The prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` is `compile-referenced-projects.ts host-contracts`, which runs host-src beside host-typert ([`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts)). `build`, `build:artifacts`, benchmark builds, and the PR rehearsal in `release-vendor.yml` run Typert inside the Host tsdown pass.
| Step | Command | `needs` |
|---|---|---|
| host-src | `node --max-old-space-size=4096 <tsc> -b <references of tsconfig.host.json>` | — |
| host-typert | `node --import tsx/esm scripts/generate-typert.ts` | — |
| client-src | `<tsc> -b <references of tsconfig.client.json>` | host-src, host-typert |
| host-check | `node --max-old-space-size=4096 <tsc> -b tsconfig.host.json` | host-src |
| client-check | `<tsc> -b tsconfig.client.json` | client-src |
- **Shared generation:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) calls `WorkspaceTypertGenerator.generateContributors()` for `WORKSPACE_TYPERT_FACES` ([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts)), the faces the Host tsdown plugin also generates, and writes the result with `writeWorkspaceArtifacts()` from [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts), the writer the plugin uses. Like the plugin, it skips TypeScript diagnostics, which Host package compilation reports; a Typert analysis error fails host-typert as it fails the Host bundle in `build`. host-typert runs under Node with tsx's ESM hook rather than through `pnpm exec`, whose startup would lengthen the path to Client compilation.
- **Aggregate checks in build mode:** host-check and client-check start after their own face's package compilation, so build mode finds every reference up to date, checks only the aggregate root program, and skips a root program whose tsbuildinfo is current.
- **Source-only analysis:** host-typert can run beside host-src because its Host program reads no workspace build output. `tsconfig.base.json` maps `@deepseek-ai/dsh-client-file-upload/types` to source; without that alias the specifier resolves through package `exports` to `lib/types/types.d.ts`. With it, the Host program loads 1,289 files, none of them under a workspace package's `lib/`. `generate-typert.ts` enables `rejectBuildOutputs`: [`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) fails analysis with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory, or cannot resolve an import of a workspace package, as on a tree whose packages are not yet compiled, naming each such import specifier and the `paths` entry that maps it to source. Either way a missing alias fails generation whether or not host-src has run. The tsdown plugin leaves the option off because in `build` Typert always runs after Host package compilation. The [Typert generator README](../../../../packages/typert/generator/README.md) documents the writers and the option.
- **Why it is lossless:** No workspace tsconfig sets `allowJs`, every `exports` `types` condition points into `lib/types/` or at `lib/typert.*`, and every tsdown config sets `dts: false`, so no type check reads tsdown output. The 140 Typert files (28 packages × 5 outputs) are byte-identical across the baseline tsdown pass, the current tsdown pass, `generate-typert.ts` after every Typert output is deleted, and `generate-typert.ts` with all 344 `lib/types` directories hidden; with one Remote method temporarily added to `packages/goal/goal`, the tsdown pass and `generate-typert.ts` also write identical files. `typecheck` checks every package project of both faces and both aggregate root programs, the programs that `build` checks.
- **Performance:** `typecheck` with nothing to recheck takes 34.1/35.0 → 21.9/20.0 s. When both aggregate root programs must be rechecked, it takes 83.5 → 76.0/72.1 s, and its critical path is the Host aggregate check at about 66–70 s. The prerequisite of `lint` and `doc-typecheck` takes 52.2/29.6 → 12.5/11.3 s.
- **Trade-off:** `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` write Typert declarations but no bundle, so runnable `lib/` JavaScript can lag behind those declarations until the next `build:artifacts`.
- **Relation to the build:** [The lossless build speed-ups note](2026-10-11-lossless-build-speedups.md) rejects moving Typert out of the Host tsdown pass for builds, where its Worker Thread already overlaps bundling and a separate step saves at most about 1 s. Type-check commands run no bundler, so generating without one removes the whole Host tsdown pass and the Desktop bundle from their critical path. [The build split note](2026-10-11-build-artifacts-check-split.md) lists every caller and its command.
- **Known gap:** Incremental `tsc -b` does not record `lib/typert.remote-client.d.ts`, which Client sources reach through package `exports`, as an input of the consuming project; when a Remote signature changes and no Client source does, an incremental check does not recheck the consumers. The gap is independent of which pass writes the declarations; Issue #6022 tracks it.
### 2. The pre-commit hook does not regenerate third-party notices
- **Change:** The `pre-commit` hook in [`lefthook.yml`](../../../../lefthook.yml) has no third-party notices job. [`gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) asserts the committed bytes of `THIRD_PARTY_NOTICES.md` in the test lane, including the required `node 24 / coverage` lane, and names `pnpm run gen-third-party-notices` when they are stale. [The generated third-party notices note](2026-07-30-generated-third-party-notices.md) owns this freshness decision.
- **Performance:** A commit that stages one file under a package `src/` subdirectory spends 4.3/4.5 s in the hooks instead of 16.5/17.8 s, of which the notices job took 12.4/13.4 s.
- **Trade-off:** A commit that leaves the notices stale fails in the test lane after push rather than being corrected at commit time.
### 3. Local default worker budget
- **Purpose:** Size default parallelism for a shared workstation or container, where Node reports host CPUs rather than the CPU quota.
- **Change:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) owns the default. With `CI=true` it is the CPU count Node reports. Otherwise it is capped at 12 (`LOCAL_PARALLELISM_CAP`) for the run-gates scheduler, `build`, `typecheck`, Oxlint, and publint, and at 48 (`LOCAL_TEST_PARALLELISM_CAP`) for Vitest forks and the local coverage budget. `doc-sync`, `test:docs`, `hygiene`, and `check:all`, whose gates each build a full `ts.Program`, run at most 8 gates concurrently, in CI as well. Explicit `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` take precedence; in CI, Vitest and Oxlint keep their own defaults unless the run-gates CI environment sets these variables. The [development guide](../../../../docs/development.md) states the defaults for contributors.
`pnpm run test` at four fork counts, with the disk-backed `type-model.spec.ts` that item 8 replaces, which sets the wall of the 32-worker run:
| Vitest workers | Wall | Peak RSS | Additional intermittent failures | Every file except the slowest finished at |
|---|---|---|---|---|
| 255 (the reported CPU count) | 243.7 s | 55.3 GiB | 12 files, 14 timeouts | about 135–170 s |
| 32 | 310.4 s | 15.7 GiB | 4 files, 4 timeouts | 193 s |
| 24 | 337.2 s | 13.2 GiB | 5 files, 5 timeouts | 233 s |
| 12 | 410.5 s | 8.4 GiB | 2 files, 3 timeouts | 406 s |
- **Oxlint:** Oxlint's own default is 64 threads, read from the cgroup quota; at 12 threads its wall stays about 87–90 s with about 15% less CPU, and at 8 threads it takes about 4 s longer.
- **Documentation aggregates:** `test:docs` takes 50.9 s at 8 concurrent gates against 74.7 s at 4. `doc-sync`, measured without item 4, takes 341.8, 295.0, and 253.2 s at 4, 8, and 12 concurrent gates, with peak RSS of 10.0, 13.5, and 16.3 GiB.
- **Trade-off:** A machine whose CPU quota exceeds the caps gets no more default parallelism than 12 or 48 and needs the override variables; a container whose quota is below a cap still oversubscribes, because Node reports host CPUs. At 8 concurrent gates `doc-sync` peaks at 13.5 GiB RSS against 10.0 GiB at 4; 12 concurrent gates finish 41.8 s sooner at 16.3 GiB. The CI documentation workflow also runs `doc-sync` at 8 concurrent gates.
### 4. Repository-reference candidates resolve against one object listing
- **Change:** [`verify-repository-references.ts`](../../../../scripts/verify-repository-references.ts) lists every object name once with `git cat-file --batch-all-objects --batch-check=%(objectname)`, about 1.2 million objects in 0.6 s, sorts the list, and binary-searches each lowercase candidate prefix in memory; one `--batch-check=%(objectname) %(objecttype)` call then reads the types of the matched objects. A candidate is a commit reference only when its prefix matches exactly one available object and that object is a commit, the rule Git's own prefix lookup applies; the listing covers unreachable and alternate objects, as Git's lookup does.
- **Performance:** 166.8 → 15.7 s.
- **Why it is lossless:** Git's per-candidate prefix lookup and the in-memory search classify all 27,050 real and synthetic candidates identically, including ambiguous prefixes, blob and tree prefixes, and random strings.
- **Trade-off:** The listing is about 49 MiB of Git output for 1.2 million objects, read under a 1 GiB output limit.
### 5. Documentation gates parse each document once
- **Change:** Each gate below parses a Markdown document once and passes the tree to every consumer in that run.
| Gate | Single-parse mechanism | Wall |
|---|---|---|
| markdown links ([`verify-md-links.ts`](../../../../scripts/verify-md-links.ts)) | One per-run cache parses each file once for its destinations and anchors and probes each resolved target once | 53.4/51.6 → 25.9/25.1 s |
| translation pairing ([`verify-translation-pairing.ts`](../../../../scripts/verify-translation-pairing.ts)) | One glob covers every scope pattern; the record reuses the authored tree under the guard below | 51.0/47.1 → 30.3/29.3 s |
| package README model experience, package README limitations | Each check parses a README once and passes the tree to `markdownProseLines()` and `markdownHeadingLines()` | about 12 → 5.9 s each |
| type equivalence ([`verify-type-equiv.ts`](../../../../scripts/verify-type-equiv.ts)) | A text prefilter skips documents that cannot hold a source-equivalence fence; each manifest TypeScript source is parsed once | 23.0/22.5 → 6.0/5.5 s |
| Session format references | The persistence renderer passes the trees it parses to the pairing record | 10.2 → 5.3 s |
- **Pairing reuse guard:** `normalizedTopLevelBlocks()` in [`translation-pairing-record.ts`](../../../../scripts/translation-pairing-record.ts) reuses the authored tree for normalized text only when every link-normalization edit replaces a complete, title-free destination that does not start a line, the authored text equals the node's `url`, and both the authored and the replacement text contain only `[A-Za-z0-9._/:#%-]`. Those characters cannot end a destination or start or end another construct, so the normalized text parses to the same nodes with shifted offsets. Any other edit falls back to a full parse of the normalized text.
- **Type-equivalence prefilter:** [`type-equiv-prefilter.ts`](../../../../scripts/type-equiv-prefilter.ts) keeps a document for parsing when it contains `type-equiv` or `public-api`, or when a line containing `` ``` `` or `~~~` also contains `&` or `\`. Parsing changes an info string only by decoding character references and backslash escapes, so `ts type&#45;equiv` and `ts type\-equiv` keep their documents; [`type-equiv-prefilter.spec.ts`](../../../../scripts/type-equiv-prefilter.spec.ts) covers both forms.
- **Why it is lossless:** Every gate's output on the repository is byte-identical, and seeded faults are reported identically. For pairing, the top-level blocks of all 1,886 corpus documents and the records of all 943 pairs match those of a full reparse, as do randomized destinations.
### 6. Repository file walks list each directory once
- **Change:** `uniqueRepoFiles()` in [`scripts/repo-files.ts`](../../../../scripts/repo-files.ts) shares one directory-listing cache across all patterns of a call, so each directory is read at most once, and calls `realpath` only for matches reached through a symbolic link; a regular file reached through real directories canonicalizes to the repository's realpath joined with its relative path. Its optional `skipDirectory` predicate keeps the walker out of a directory whose every path the caller excludes; [`verify-doc-refs.ts`](../../../../scripts/verify-doc-refs.ts) skips every `lib` directory below the first path segment, whose files it excludes in any case.
- **Performance:** markdown wrap 25.8/25.0 → 21.3/21.2 s; doc refs 8.8/7.0 → 3.2/3.4 s; package paths 9.9/8.1 → 7.2/5.5 s.
- **Why it is lossless:** All three gates receive identical file lists, and a broken or cyclic symbolic link still fails through `realpath`.
- **Behavior difference:** doc refs does not list a skipped `lib` directory, so an unreadable one does not fail the gate with EACCES.
### 7. CLI help reads run concurrently; doc-graph probes are memoized
- **CLI help:** `prefetchHelp()` in [`scripts/gen-cli-help.ts`](../../../../scripts/gen-cli-help.ts) starts the 12 top-level `dsh … --help` reads (the launcher, `plugin`, and each profile's shorthand and `--profile` forms) within the default worker budget, each with its own temporary `DSH_HOME` and `DSH_AGENTS_HOME`, and the in-order traversal consumes them; nested reads share the same limit. A failed read reaches the traversal when it requests that invocation, and `close()` aborts queued and running reads and waits for every started child. The gate takes 49.9/53.9 → 6.9/7.2 s.
- **Doc graphs:** `withMemoizedFileSystemProbes()` in [`scripts/gen-doc-graphs.ts`](../../../../scripts/gen-doc-graphs.ts) memoizes `ts.sys` `fileExists`, `directoryExists`, `realpath`, `readDirectory`, and `getDirectories` while `renderDocs` runs, so every compiler host and config parse in one run shares one view of the file system. The workspace must not change during the run, and the original methods are restored before pages are written. The gate takes 48.7/44.7 → 32.7/34.2 s.
- **Why it is lossless:** Both generators produce byte-identical pages.
- **Trade-off:** The concurrent help processes use about 3.6 GB of memory combined at peak.
### 8. `type-model.spec.ts` runs against an in-memory workspace
- **Change:** [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) mocks `node:fs` with `memoryFileSystem()` and the `ts.sys` host from [`type-model-workspace.ts`](../../../../packages/typert/generator/tests/type-model-workspace.ts), so fixture copies, case edits, write-mode annotations, tsconfig expansion, and module resolution under the in-memory root never touch the disk. Each workspace mounts minimal `zod` and `@types/node` declaration stubs; only the Emitter case keeps real zod. The fixture's `tsconfig.base.json` sets `lib: ["ES2024"]`, which excludes the DOM library, and cases that read the unmodified fixture share one deep-frozen analysis. The generator source has no test-specific path.
- **Performance:** The file alone takes 316.3/329.1 → 14.65/15.04 s.
- **Why the behavior coverage stays:** The 61 case names and their assertions are the same. The snapshot differs in one line, the external zod symbol path, which names the stub (`<external>:node_modules/zod/index.d.ts#ZodType`) rather than a pnpm store version.
- **Trade-off:** Outside the Emitter case, the spec does not exercise the analyzer against the real file system, the DOM library, or the real zod and `@types/node` declarations. The memory file system implements only the `node:fs` calls the analyzer makes (`existsSync`, `readFileSync`, `realpathSync`, and `writeFileSync`); another call on an in-memory path reaches the real file system, where the path does not exist.
### Repository-wide comparison
Both trees were measured in the same session, alternating baseline and implementation runs of each command after `pnpm run build:artifacts` (`pnpm run test` fails the same environment-dependent files on both trees, 42 on the baseline and a subset of 28 here):
| Command | Baseline | This implementation | Change | Items |
|---|---|---|---|---|
| `pre-commit` hooks, one staged file under a package `src/` subdirectory | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 |
| `pnpm run typecheck` (pre-push), nothing to recheck | 29.7/23.3 s | 14.2/16.1 s | −43% | 1, 3 |
| `pnpm run lint` | 110.0 s | 106.7 s | −3% | 1 |
| `pnpm run doc-typecheck` | 45.2 s | 36.1 s | −20% | 1 |
| `pnpm run test:docs` | 102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 |
| `pnpm run doc-sync` | 186.8 s | 70.3 s | −62% | 3–7 |
| `pnpm run test` | 222.7 s (255 workers) | 148.1 s (48 workers) | −34% | 3, 8 |
`lint` time is Oxlint itself; its prerequisite falls as item 1 states. CPU time falls with wall: `typecheck` user time 39.5 → 14.2 s, `test:docs` 212.6 → 126.3 s, `doc-sync` 628.2 → 503.2 s, and `test` 3,417 → 3,014 s.
## Alternatives considered
- **Cap every local default at 12, including Vitest forks (item 3):** `pnpm run test` takes 410.5 s at 12 workers against 243.7 s at 255, 68% longer.
- **Use half of the cgroup CPU quota as the local budget (item 3):** Reading the quota is a larger change and helps only inside containers.
- **Parse documents in parallel `worker_threads` (item 5):** It adds more complexity than parsing each document once, which removes the repeated work itself.
- **Regenerate third-party notices in the pre-commit hook (item 2):** Browser inputs make every package source file a generator input, so the hook runs the 16-second generator on nearly every commit while few commits change its output; [the generated third-party notices note](2026-07-30-generated-third-party-notices.md) owns the decision.
## Consequences
The pre-push `typecheck` with nothing to recheck takes about 14–22 s, the `lint` and `doc-typecheck` prerequisite about 12 s, and the hooks of a one-file commit about 4 s. `pnpm run test` starts at most 48 forks locally, and `test:docs` and `doc-sync` run up to 8 gates at once. Typert outputs, generated pages, file lists, and reference classifications are byte-identical to the baseline.
Costs: type-check commands do not refresh runnable bundles; a stale notices file fails in the test lane after push; local defaults ignore CPUs above the caps and still oversubscribe a container whose quota is below them; 8 concurrent documentation gates raise the `doc-sync` RSS peak; CLI help uses about 3.6 GB at peak; doc graphs and markdown links assume the tree does not change during a run; `type-model.spec.ts` exercises stubs rather than real zod, `@types/node`, the DOM library, and disk I/O outside the Emitter case; and incremental `typecheck` inherits the Remote-declaration gap that Issue #6022 tracks.
Verification: `generate-typert.spec.ts` pins byte-identical files against the workspace-mode plugin, rejection of a Host program that loads a compiled workspace declaration with the missing alias named, and each failure stage; `compile-referenced-projects.spec.ts` pins the `host-contracts` steps and their failure reporting; `build.spec.ts` pins the typecheck scope's steps; `parallelism.spec.ts`, `run-gates.spec.ts`, and `run-oxlint.spec.ts` pin the CI and local budgets and the overrides; `repo-files.spec.ts`, `markdown.spec.ts`, `translation-pairing.spec.ts`, `type-equiv-prefilter.spec.ts`, `verify-md-links.spec.ts`, `gen-cli-help.spec.ts`, and `gen-doc-graphs.spec.ts` pin single directory listing, parse reuse, the pairing guard, the prefilter, the prefetch order and failure path, and probe memoization with restoration.
@@ -0,0 +1,158 @@
# Agent Note: 本地门禁、钩子与测试提速
Status: implemented
[English](2026-10-12-local-gate-speedups.md) | 中文
## 问题
在基线(合并 #6013 时的 master)上,本地运行 Git 钩子、`pnpm run test`、`test:docs` 与 `doc-sync`(文档同步门禁)时,大部分 wall 时间花在结果并不依赖的工作上:
- **经打包器获得 Typert 声明。** pre-push 钩子运行的 `typecheck` 先运行 `build:lib:host`(Host `tsc -b tsconfig.host.json`、Host tsdown 阶段与 Desktop bundle),再运行 `tsc -b tsconfig.client.json`。`lint`、`lint:fix`、`doc-typecheck` 与 CI `typert-contracts` 门禁的前置步骤先运行 Host 包编译,再运行 Host tsdown 阶段(`compile-referenced-projects.ts host-libraries`)。类型检查只读取包声明与 Typert 声明,但无需重查时 `typecheck` 仍需 34.1/35.0 s,前置步骤需 52.2/29.6 s。
- **每次提交都生成第三方声明。** 只要暂存文件是生成器的输入,pre-commit 钩子就重新生成 `THIRD_PARTY_NOTICES.md`。为找出浏览器侧的第三方 import,生成器对 75 个 Client 包试打包并运行一次 Vite 构建,因此每个包源码文件都是它的输入;暂存一个位于包 `src/` 子目录下的文件的提交,钩子合计 16.5/17.8 s,其中第三方声明任务占 12.4/13.4 s。
- **以宿主 CPU 数作为本地 worker 数。** `os.availableParallelism()` 报告的是宿主 CPU 数(测量主机上为 256),而不是容器的 CPU 配额(64)。按此默认值,`pnpm run test` 启动 255 个 Vitest fork,RSS 峰值 55.3 GiB,并偶发地多失败 12 个文件(14 次超时);同时 `doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多只并发 4 个门禁。
- **每个候选一次 Git 前缀查找。** `verify-repository-references` 通过 `git cat-file --batch-check` 对约 26,000 个缩写十六进制候选逐个做 Git 前缀查找;这些查找占该门禁 166.8 s 中的 135.7 s。
- **重复解析 Markdown。** markdown links 对每个锚点目标再多解析两次,且探测目标是否存在时不缓存;translation pairing 做四次 glob,并在链接归一化后再次解析每个文档;包 README 的 model experience 与 limitations 检查各自把每个 README 解析三次;type equivalence 解析全部 1,835 个文档;Session format references 把每个配对解析四次。
- **重复读取目录。** markdown wrap、doc refs 与 package paths 背后的遍历器 `uniqueRepoFiles` 对每个 pattern 与每层 `**` 各读取一次每个目录,并对每个匹配文件调用 `realpath`。
- **串行启动 help 与重复的编译器探测。** `gen-cli-help` 依次启动 12 个顶层源码模式的 `dsh … --help` 进程,每个进程大部分时间在等待 profile 启动的 I/O(49.9/53.9 s);`gen-doc-graphs` 构建多个编译器 host 与 program,重复执行同样的 `ts.sys` 文件系统探测(48.7/44.7 s)。
- **受文件系统探测拖累的测试文件。** Typert generator 的 `type-model.spec.ts` 的 61 个用例各自把 fixture(测试前置数据)复制到磁盘并运行一次完整分析,分析中的四个 TypeScript program 各自加载 DOM lib、`@types/node` 与 zod v4,每次分析约 22,000 次 stat 调用。该文件耗时 316.3/329.1 s,是 `pnpm run test` 的长尾。
## 决策
以下八项决策缩短上述运行。每一项都给出其产物与诊断保持不变的依据,或它接受的有意差异。
| # | 决策 | 受影响的命令 | 实测 wall | 产物与诊断 | 有意的行为差异 |
|---|---|---|---|---|---|
| 1 | 类型检查不打包地生成 Typert 声明 | `typecheck`、`lint`、`lint:fix`、`doc-typecheck`、CI `typert-contracts` | `typecheck` 34.1/35.0 → 21.9/20.0 s;前置步骤 52.2/29.6 → 12.5/11.3 s | 140 个 Typert 文件逐字节一致;检查的程序相同 | 不产出可运行 bundle,不写 client build record |
| 2 | pre-commit 钩子不重新生成第三方声明 | `pre-commit` | 16.5/17.8 → 4.3/4.5 s | 新鲜度由测试 lane 断言 | 第三方声明过期时在测试 lane 失败,而不是在提交时 |
| 3 | 本地默认 worker 预算 | run-gates 聚合、`build`、`typecheck`、`pnpm run test`、Oxlint、publint | 255 个 worker 时 `test` 为 243.7 s、55.3 GiB;`test:docs` 74.7 → 50.9 s | 检查不变 | 本地默认值不使用超过 12 或 48 的 CPU |
| 4 | 仓库引用候选对照一次对象列表解析 | `verify-repository-references` | 166.8 → 15.7 s | 27,050 个候选的判定 0 差异 | 无 |
| 5 | 文档门禁每个文档只解析一次 | markdown links、translation pairing、README model experience 与 limitations、type equivalence、Session format references | markdown links 53.4/51.6 → 25.9/25.1 s;type equivalence 23.0/22.5 → 6.0/5.5 s | 门禁输出逐字节一致 | 无 |
| 6 | 仓库文件遍历每个目录只列一次 | markdown wrap、doc refs、package paths | doc refs 8.8/7.0 → 3.2/3.4 s | 文件列表一致 | doc refs 不读取嵌套的 `lib` 目录 |
| 7 | CLI(命令行界面)help 读取并发运行;doc graphs 的探测做 memo | `verify-cli-help`、`verify-doc-graphs` | 49.9/53.9 → 6.9/7.2 s;48.7/44.7 → 32.7/34.2 s | 页面逐字节一致 | CLI help 峰值约用 3.6 GB 内存 |
| 8 | `type-model.spec.ts` 在内存工作区上运行 | `pnpm run test` | 316.3/329.1 → 14.65/15.04 s | 61 个用例与断言不变;快照有一行不同 | 除 Emitter 用例外,桩替代 zod、`@types/node` 与 DOM lib |
### 测量条件
所有数字都是 wall 时间,取自同一个共享 Linux 容器:CPU 配额 64 核,宿主报告 256 个 CPU,一分钟 load average 为 20–150,因此样本带有噪声。工作树位于网络文件系统上,每次文件系统探测的代价都高于本地磁盘。`a/b` 表示两个样本,`x → y` 比较基线与本实现。
### 1. 类型检查不打包地生成 Typert 声明
- **目的:** 让 `typecheck` 以及 `lint`、`lint:fix`、`doc-typecheck` 与 CI `typert-contracts` 门禁的前置步骤得到它们读取的声明,即 `tsc` 产出的包声明与 Typert 声明,而不运行 Host tsdown 阶段或 Desktop bundle。
- **改动:** `typecheck` 是 `tsx scripts/build.ts --scope typecheck`,它通过 run-gates 调度器运行下表中的步骤([`scripts/build.ts`](../../../../scripts/build.ts))。该 scope 不构建 native addon、tsdown bundle、Desktop bundle 或 Web 产物,不写 client build record,可运行的 `lib/` 产物保持上一次 `build:artifacts` 的状态;启动或测试构建产物之前先运行 `build:artifacts`。`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 的前置步骤是 `compile-referenced-projects.ts host-contracts`,它让 host-src 与 host-typert 并行([`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts))。`build`、`build:artifacts`、benchmark 构建与 `release-vendor.yml` 的 PR(Pull Request)演练在 Host tsdown 阶段内运行 Typert。
| 步骤 | 命令 | `needs` |
|---|---|---|
| host-src | `node --max-old-space-size=4096 <tsc> -b <references of tsconfig.host.json>` | — |
| host-typert | `node --import tsx/esm scripts/generate-typert.ts` | — |
| client-src | `<tsc> -b <references of tsconfig.client.json>` | host-src、host-typert |
| host-check | `node --max-old-space-size=4096 <tsc> -b tsconfig.host.json` | host-src |
| client-check | `<tsc> -b tsconfig.client.json` | client-src |
- **共用生成:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) 对 `WORKSPACE_TYPERT_FACES`([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts),即 Host tsdown 插件同样生成的 face)调用 `WorkspaceTypertGenerator.generateContributors()`,并用 [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts) 中插件同样使用的 `writeWorkspaceArtifacts()` 写出结果。与插件一样,它跳过 TypeScript 诊断,这些诊断由 Host 包编译报告;Typert 分析错误使 host-typert 失败,正如它在 `build` 中使 Host bundle 失败。host-typert 由 Node 加 tsx 的 ESM 钩子运行,而不经 `pnpm exec`,因为后者的启动开销会拉长通往 Client 编译的路径。
- **以 build mode 运行聚合检查:** host-check 与 client-check 在本侧 face 的包编译之后才开始,因此 build mode 发现所有 references 都是最新的,只检查聚合根程序,并在根程序的 tsbuildinfo 为最新时跳过它。
- **只读源码的分析:** host-typert 能与 host-src 并行,是因为它的 Host program 不读取任何工作区构建产物。`tsconfig.base.json` 把 `@deepseek-ai/dsh-client-file-upload/types` 映射到源码;没有这条别名时,该说明符经包 `exports` 解析到 `lib/types/types.d.ts`。有了它,Host program 加载 1,289 个文件,其中没有一个位于工作区包的 `lib/` 下。`generate-typert.ts` 开启 `rejectBuildOutputs`:face program 加载工作区包 `lib/` 目录下的文件,或无法解析对工作区包的 import(例如包尚未编译的工作区)时,[`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) 以 `TypertAnalysisError` 使分析失败,并指出每个这样的 import 说明符,以及把它映射到源码的 `paths` 条目。因此无论 host-src 是否已运行,缺失的别名都会使生成失败。tsdown 插件不开启该选项,因为在 `build` 中 Typert 总在 Host 包编译之后运行。[Typert generator README](../../../../packages/typert/generator/README.zh.md) 记录了这些写入函数与该选项。
- **无损依据:** 没有任何工作区 tsconfig 设置 `allowJs`,每个 `exports` 的 `types` 条件都指向 `lib/types/` 内或 `lib/typert.*`,每个 tsdown 配置都设置 `dts: false`,因此没有类型检查读取 tsdown 产物。140 个 Typert 文件(28 个包 × 5 个输出)在以下场景中逐字节一致:基线 tsdown 阶段、当前 tsdown 阶段、删除全部 Typert 输出后运行 `generate-typert.ts`、隐藏全部 344 个 `lib/types` 目录后运行 `generate-typert.ts`;临时给 `packages/goal/goal` 加一个 Remote 方法时,tsdown 阶段与 `generate-typert.ts` 写出的文件也相同。`typecheck` 检查两个 face 的全部包 project 与两个聚合根程序,即 `build` 检查的程序。
- **性能:** 无需重查时 `typecheck` 为 34.1/35.0 → 21.9/20.0 s。两个聚合根程序都需要重查时为 83.5 → 76.0/72.1 s,其关键路径是约 66–70 s 的 Host 聚合检查。`lint` 与 `doc-typecheck` 的前置步骤为 52.2/29.6 → 12.5/11.3 s。
- **取舍:** `typecheck`、`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 写出 Typert 声明但不写 bundle,因此在下一次 `build:artifacts` 之前,可运行的 `lib/` JavaScript 可能落后于这些声明。
- **与构建的关系:** [无损构建提速 Note](2026-10-11-lossless-build-speedups.zh.md) 否决了在构建中把 Typert 移出 Host tsdown 阶段:构建中它的 Worker Thread 已与打包重叠,独立步骤最多只能省约 1 s。类型检查命令不运行打包器,因此不打包地生成会把整个 Host tsdown 阶段与 Desktop bundle 移出它们的关键路径。[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md) 列出每个调用方及其命令。
- **已知缺口:** 增量 `tsc -b` 不把 Client 源码经包 `exports` 访问到的 `lib/typert.remote-client.d.ts` 记为消费方 project 的输入;Remote 签名改变而 Client 源码未变时,增量检查不会重查消费方。这一缺口与由哪一种生成写出声明无关,由 Issue #6022 跟踪。
### 2. pre-commit 钩子不重新生成第三方声明
- **改动:** [`lefthook.yml`](../../../../lefthook.yml) 中的 `pre-commit` 钩子没有第三方声明任务。[`gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 在测试 lane(包括必过的 `node 24 / coverage` lane)中断言 `THIRD_PARTY_NOTICES.md` 已提交的字节,过期时指出 `pnpm run gen-third-party-notices`。[生成的第三方声明 Note](2026-07-30-generated-third-party-notices.zh.md) 拥有这一新鲜度决策。
- **性能:** 暂存一个位于包 `src/` 子目录下的文件的提交,钩子耗时由 16.5/17.8 s 降为 4.3/4.5 s,其中第三方声明任务占 12.4/13.4 s。
- **取舍:** 使第三方声明过期的提交会在推送后于测试 lane 失败,而不是在提交时被修正。
### 3. 本地默认 worker 预算
- **目的:** 按共享工作站或容器确定默认并行度;在这类环境中 Node 报告的是宿主 CPU 数而不是 CPU 配额。
- **改动:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) 拥有默认值。`CI=true` 时它是 Node 报告的 CPU 数。否则,run-gates 调度器、`build`、`typecheck`、Oxlint 与 publint 的上限为 12(`LOCAL_PARALLELISM_CAP`),Vitest fork 与本地覆盖率预算的上限为 48(`LOCAL_TEST_PARALLELISM_CAP`)。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 的每个门禁都构建完整的 `ts.Program`,它们最多并发 8 个门禁,CI 中同样如此。显式的 `DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest `--maxWorkers` 优先;CI 中,除非 run-gates 的 CI 环境设置了这些变量,Vitest 与 Oxlint 使用各自的默认值。[开发指南](../../../../docs/development.zh.md)向贡献者说明这些默认值。
四种 fork 数下的 `pnpm run test`,运行的是第 8 项所替换的落盘版 `type-model.spec.ts`,32 个 worker 那次的 wall 由它决定:
| Vitest worker 数 | wall | RSS 峰值 | 额外的偶发失败 | 除最慢文件外其余文件完成于 |
|---|---|---|---|---|
| 255(报告的 CPU 数) | 243.7 s | 55.3 GiB | 12 个文件、14 次超时 | 约 135–170 s |
| 32 | 310.4 s | 15.7 GiB | 4 个文件、4 次超时 | 193 s |
| 24 | 337.2 s | 13.2 GiB | 5 个文件、5 次超时 | 233 s |
| 12 | 410.5 s | 8.4 GiB | 2 个文件、3 次超时 | 406 s |
- **Oxlint:** Oxlint 自身的默认值是 64 线程(读取 cgroup 配额);12 线程时其 wall 保持约 87–90 s,CPU 少约 15%;8 线程时慢约 4 s。
- **文档聚合:** 并发 8 个门禁时 `test:docs` 为 50.9 s,并发 4 个时为 74.7 s。不含第 4 项时测得的 `doc-sync` 在并发 4、8、12 个门禁时分别为 341.8、295.0、253.2 s,RSS 峰值分别为 10.0、13.5、16.3 GiB。
- **取舍:** CPU 配额超过上限的机器,默认并行度也不超过 12 或 48,需要使用覆盖变量;配额低于上限的容器仍会超额订阅,因为 Node 报告的是宿主 CPU 数。并发 8 个门禁时 `doc-sync` 的 RSS 峰值为 13.5 GiB,并发 4 个时为 10.0 GiB;并发 12 个门禁快 41.8 s,但峰值为 16.3 GiB。CI 的文档 workflow 运行 `doc-sync` 时同样最多并发 8 个门禁。
### 4. 仓库引用候选对照一次对象列表解析
- **改动:** [`verify-repository-references.ts`](../../../../scripts/verify-repository-references.ts) 用 `git cat-file --batch-all-objects --batch-check=%(objectname)` 一次列出全部对象名(约 120 万个对象,0.6 s),排序后在内存中对每个小写候选前缀做二分查找;随后一次 `--batch-check=%(objectname) %(objecttype)` 调用读取命中对象的类型。只有当候选前缀恰好匹配一个可用对象且该对象是 commit 时,它才算 commit 引用,这与 Git 自身前缀查找的规则相同;与 Git 的查找一样,该列表也包含不可达对象与 alternate 对象。
- **性能:** 166.8 → 15.7 s。
- **无损依据:** 在 27,050 个真实与合成候选上(包括歧义前缀、blob 与 tree 前缀以及随机字符串),Git 的逐候选前缀查找与内存查找的判定完全一致。
- **取舍:** 120 万个对象的列表约为 49 MiB 的 Git 输出,读取时的输出上限为 1 GiB。
### 5. 文档门禁每个文档只解析一次
- **改动:** 下表中的每个门禁对一个 Markdown 文档只解析一次,并把语法树传给本次运行中的所有使用方。
| 门禁 | 单次解析机制 | wall |
|---|---|---|
| markdown links([`verify-md-links.ts`](../../../../scripts/verify-md-links.ts)) | 一个按运行划分的缓存对每个文件只解析一次,同时取得其 destination 与锚点,并对每个解析后的目标只探测一次 | 53.4/51.6 → 25.9/25.1 s |
| translation pairing([`verify-translation-pairing.ts`](../../../../scripts/verify-translation-pairing.ts)) | 一次 glob 覆盖所有范围 pattern;record 在下述守卫条件下复用原文语法树 | 51.0/47.1 → 30.3/29.3 s |
| package README model experience、package README limitations | 每项检查对一个 README 只解析一次,并把语法树传给 `markdownProseLines()` 与 `markdownHeadingLines()` | 各约 12 → 5.9 s |
| type equivalence([`verify-type-equiv.ts`](../../../../scripts/verify-type-equiv.ts)) | 文本预筛跳过不可能含源码等价围栏的文档;manifest(元数据清单)中的每个 TypeScript 源文件只解析一次 | 23.0/22.5 → 6.0/5.5 s |
| Session format references | persistence 渲染器把它解析出的语法树传给配对 record | 10.2 → 5.3 s |
- **配对复用守卫:** [`translation-pairing-record.ts`](../../../../scripts/translation-pairing-record.ts) 中的 `normalizedTopLevelBlocks()` 仅在以下条件同时成立时,才为归一化后的文本复用原文语法树:每处链接归一化编辑都替换一个完整、无 title 且不在行首开始的 destination,原文等于该节点的 `url`,且原文与替换文本都只含 `[A-Za-z0-9._/:#%-]`。这些字符既不能结束 destination,也不能开始或结束其他语法结构,因此归一化后的文本解析出相同的节点,只是偏移量随之移动。其他任何编辑都回退为对归一化文本的完整解析。
- **type equivalence 预筛:** [`type-equiv-prefilter.ts`](../../../../scripts/type-equiv-prefilter.ts) 在文档含 `type-equiv` 或 `public-api`,或者某个含 `` ``` `` 或 `~~~` 的行同时含 `&` 或 `\` 时,保留该文档进行解析。解析只会通过解码字符引用与反斜杠转义改变信息字符串,因此 `ts type&#45;equiv` 与 `ts type\-equiv` 所在的文档都会被保留;[`type-equiv-prefilter.spec.ts`](../../../../scripts/type-equiv-prefilter.spec.ts) 覆盖这两种写法。
- **无损依据:** 每个门禁在仓库上的输出逐字节一致,人为注入的错误也被同样报告。对配对而言,语料中全部 1,886 个文档的顶层块与全部 943 个配对的 record 都与完整重新解析的结果一致,随机 destination 的结果也一致。
### 6. 仓库文件遍历每个目录只列一次
- **改动:** [`scripts/repo-files.ts`](../../../../scripts/repo-files.ts) 中的 `uniqueRepoFiles()` 在一次调用的所有 pattern 之间共享一个目录列表缓存,使每个目录最多读取一次,并且只对经符号链接到达的匹配调用 `realpath`;只经真实目录到达的普通文件,规范路径为仓库 realpath 拼接其相对路径。它的可选谓词 `skipDirectory` 让遍历器不进入调用方排除其下全部路径的目录;[`verify-doc-refs.ts`](../../../../scripts/verify-doc-refs.ts) 跳过第一段路径之后的每个 `lib` 目录,其下文件本来就全部被它排除。
- **性能:** markdown wrap 25.8/25.0 → 21.3/21.2 s;doc refs 8.8/7.0 → 3.2/3.4 s;package paths 9.9/8.1 → 7.2/5.5 s。
- **无损依据:** 三个门禁得到的文件列表逐项相同,损坏或循环的符号链接仍会经 `realpath` 失败。
- **行为差异:** doc refs 不列出被跳过的 `lib` 目录,因此不可读的此类目录不会以 EACCES 使门禁失败。
### 7. CLI help 读取并发运行;doc graphs 的探测做 memo
- **CLI help:** [`scripts/gen-cli-help.ts`](../../../../scripts/gen-cli-help.ts) 中的 `prefetchHelp()` 在默认 worker 预算内启动 12 个顶层 `dsh … --help` 读取(启动器、`plugin`,以及每个 profile 的简写与 `--profile` 形式),每个读取使用独立的临时 `DSH_HOME` 与 `DSH_AGENTS_HOME`,并由按原顺序进行的遍历消费;嵌套读取共享同一上限。失败的读取在遍历请求该调用时才交给遍历;`close()` 中止排队与运行中的读取,并等待每个已启动的子进程结束。该门禁为 49.9/53.9 → 6.9/7.2 s。
- **doc graphs:** [`scripts/gen-doc-graphs.ts`](../../../../scripts/gen-doc-graphs.ts) 中的 `withMemoizedFileSystemProbes()` 在 `renderDocs` 运行期间对 `ts.sys` 的 `fileExists`、`directoryExists`、`realpath`、`readDirectory` 与 `getDirectories` 做 memo,使一次运行中的所有编译器 host 与配置解析共享同一个文件系统视图。运行期间工作区不得改变,写出页面之前恢复原方法。该门禁为 48.7/44.7 → 32.7/34.2 s。
- **无损依据:** 两个生成器生成的页面逐字节一致。
- **取舍:** 并发的 help 进程合计峰值约占 3.6 GB 内存。
### 8. `type-model.spec.ts` 在内存工作区上运行
- **改动:** [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 用 [`type-model-workspace.ts`](../../../../packages/typert/generator/tests/type-model-workspace.ts) 中的 `memoryFileSystem()` mock `node:fs` 与 `ts.sys` host,使内存根目录下的 fixture 复制、用例编辑、写模式注解、tsconfig 展开与模块解析都不触及磁盘。每个工作区挂载最小的 `zod` 与 `@types/node` 声明桩;只有 Emitter 用例保留真实 zod。fixture 的 `tsconfig.base.json` 设置 `lib: ["ES2024"]`,不含 DOM lib;读取未修改 fixture 的用例共享一份深度冻结的分析结果。generator 源码没有任何测试专用路径。
- **性能:** 单独运行该文件为 316.3/329.1 → 14.65/15.04 s。
- **行为覆盖保持不变的依据:** 61 个用例名及其断言相同。快照只有一行不同,即外部 zod 符号路径,它指向桩(`<external>:node_modules/zod/index.d.ts#ZodType`),而不是某个 pnpm store 版本。
- **取舍:** 除 Emitter 用例外,该 spec 不针对真实文件系统、DOM lib 以及真实 zod 与 `@types/node` 声明检验分析器。内存文件系统只实现分析器使用的 `node:fs` 调用(`existsSync`、`readFileSync`、`realpathSync` 与 `writeFileSync`);对内存路径的其他调用会落到真实文件系统,而该路径并不存在。
### 全仓对比
两棵树在同一时段测量,每个命令在 `pnpm run build:artifacts` 之后交替运行基线与本实现(`pnpm run test` 在两棵树上失败的都是依赖环境的文件,基线 42 个,本实现是其中的 28 个):
| 命令 | 基线 | 本实现 | 变化 | 对应项 |
|---|---|---|---|---|
| `pre-commit` 钩子,暂存一个位于包 `src/` 子目录下的文件 | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 |
| `pnpm run typecheck`(pre-push),无需重查 | 29.7/23.3 s | 14.2/16.1 s | −43% | 1、3 |
| `pnpm run lint` | 110.0 s | 106.7 s | −3% | 1 |
| `pnpm run doc-typecheck` | 45.2 s | 36.1 s | −20% | 1 |
| `pnpm run test:docs` | 102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 |
| `pnpm run doc-sync` | 186.8 s | 70.3 s | −62% | 3–7 |
| `pnpm run test` | 222.7 s(255 个 worker) | 148.1 s(48 个 worker) | −34% | 3、8 |
`lint` 的耗时主要是 Oxlint 本身,其前置步骤的下降见第 1 项。CPU 时间随 wall 一同下降:`typecheck` 的 user 时间 39.5 → 14.2 s,`test:docs` 212.6 → 126.3 s,`doc-sync` 628.2 → 503.2 s,`test` 3,417 → 3,014 s。
## 考虑过的替代方案
- **所有本地默认值一律以 12 为上限,包括 Vitest fork(第 3 项):** 12 个 worker 时 `pnpm run test` 为 410.5 s,255 个时为 243.7 s,慢 68%。
- **以 cgroup CPU 配额的一半作为本地预算(第 3 项):** 读取配额的改动更大,且只在容器内有意义。
- **用 `worker_threads` 并行解析文档(第 5 项):** 它带来的复杂度高于每个文档只解析一次,而后者直接消除了重复工作。
- **在 pre-commit 钩子中重新生成第三方声明(第 2 项):** 浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每次提交都运行约 16 秒的生成器,而改变输出的提交很少;[生成的第三方声明 Note](2026-07-30-generated-third-party-notices.zh.md) 拥有该决策。
## 后果
无需重查时 pre-push `typecheck` 约 14–22 s,`lint` 与 `doc-typecheck` 的前置步骤约 12 s,改动 1 个文件的提交的钩子约 4 s。`pnpm run test` 在本地最多启动 48 个 fork,`test:docs` 与 `doc-sync` 最多同时运行 8 个门禁。Typert 输出、生成的页面、文件列表与引用判定都与基线逐字节一致。
代价:类型检查命令不刷新可运行的 bundle;第三方声明过期时在推送后于测试 lane 失败;本地默认值不使用超过上限的 CPU,而配额低于上限的容器仍会超额订阅;并发 8 个文档门禁抬高 `doc-sync` 的 RSS 峰值;CLI help 峰值约用 3.6 GB 内存;doc graphs 与 markdown links 假定运行期间工作树不变;除 Emitter 用例外,`type-model.spec.ts` 检验的是桩,而不是真实 zod、`@types/node`、DOM lib 与磁盘 I/O;增量 `typecheck` 继承 Issue #6022 跟踪的 Remote 声明缺口。
验证:`generate-typert.spec.ts` 固定与 workspace 模式插件逐字节一致的文件、对加载已编译工作区声明的 Host program 的拒绝(并指出缺失的别名),以及每个失败阶段;`compile-referenced-projects.spec.ts` 固定 `host-contracts` 的步骤及其失败报告;`build.spec.ts` 固定 typecheck scope 的步骤;`parallelism.spec.ts`、`run-gates.spec.ts` 与 `run-oxlint.spec.ts` 固定 CI 与本地预算以及覆盖变量;`repo-files.spec.ts`、`markdown.spec.ts`、`translation-pairing.spec.ts`、`type-equiv-prefilter.spec.ts`、`verify-md-links.spec.ts`、`gen-cli-help.spec.ts` 与 `gen-doc-graphs.spec.ts` 固定目录只列一次、语法树复用、配对守卫、预筛、预取顺序与失败路径,以及探测 memo 及其恢复。
@@ -0,0 +1,31 @@
# Bilingual-pair consistency record for 2026-10-11-user-text-credential-matching.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/testing/2026-10-11-user-text-credential-matching.md
/agent-note-linear-credential-matching-and-performance-gates-for-user-text:
en: 4d3e2c3ad91a1a03
zh: d08dd77dd0bd849c
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/problem:
en: ae449178436337ea
zh: 9eced057b7d9ef3d
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision:
en: a15c7c13f167e5ac
zh: ff261e3705c47ff0
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/algorithm-what-each-part-does-and-costs:
en: aa1d65cd64a2a895
zh: 4bf32f20d14ce452
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/memory:
en: b91fc88cdd488ce3
zh: 318db3f522681cca
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/trade-offs:
en: f07bb6fbbdd01fe4
zh: 9313a0ef2a22af12
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate:
en: 6a303115f85ed4e8
zh: e894f3e9d630bf57
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered:
en: 1e21f5f9ca959f7e
zh: 401e303071b8d426
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences:
en: 8f9d25b72d3e7660
zh: e5c6ae0194905876
@@ -0,0 +1,67 @@
# Agent Note: Linear credential matching and performance gates for user text
Status: implemented
English | [中文](2026-10-11-user-text-credential-matching.zh.md)
## Problem
[ui-secret-mask](../../../../packages/client/ui-secret-mask/README.md) recognizes credentials in every sent user text. The UserText scheduler queries the matcher again after each accepted node, so a text costs the sum of all its queries. Most text contains no credential, so the first goal is that such text pays as little as possible; second, text with credentials pays in proportion to their number and length; adversarial input must stay linear. Constraints: no regex backtracking, prefix pruning, linear total over all queries of one text, and no allocation before a node is returned.
## Decision
### Algorithm: what each part does and costs
| Part | Method | Cost over all queries of one text |
|---|---|---|
| Finding format starts | All starting literals (`sk-`, `sk_live_` and kin, `AIza`, `ghp_` and kin, `github_pat_`, `glpat-`, `npm_`, `hf_`, `AKIA`/`ASIA`, `LTAI`, `AKID`, `xox?-`, `whsec_`, `eyJ`, `-----BEGIN `, `://`) form one literal-only alternation grouped by shared leading characters, scanned with `test` | Θ(n) native comparisons; no quantifier, so no backtracking; two adjacent queries reread at most one literal |
| Overlapping literals | Where a literal's suffix begins another literal (the `-` of `sk-`, `glpat-`, `xox?-` and `-----BEGIN `), the scan resumes at that suffix; resume offsets are computed from the literal table at module load | At most one character back per hit |
| Identifying the hit | A table keyed by the hit's last two code units; only the three shared endings (`t_`, `e_`, `IA`) are told apart with `startsWith` | O(1) per hit |
| Format checks | Word boundaries, lengths, digit position, and the `.eyJ` separator are numeric comparisons; a private-key label reads at most 52 characters and a scheme at most 16 back; character runs are measured natively with sticky `[class]*` patterns, which stop at the first character outside the class and have nothing after them to backtrack into | O(1) per hit plus run measurement |
| Runs, next digit, next `END` line | Each matcher instance memoizes per text in one `Int32Array`: four slots for `[A-Za-z0-9_-]` (a JWT uses three runs at once), one slot per other class, the next digit, and the next valid `END` line. These are facts of the text, reused across queries and cleared when a query names another text | Each character measured at most once per class |
| Address passwords | When the `://` precedes the query offset and the password follows it, the matcher first confirms that `:`, a nonempty password, and `@` follow the offset, and only then walks back over the user name; an offset inside `://` is checked directly | At most 258 characters back, only where `:password@` follows the offset |
| Joining candidates | Candidates are processed in start order; an address password starts after its `://` and waits until the scan passes its start, and no other `://` can occur before then because user names and passwords exclude `/`; overlapping candidates join the earliest and take its mask; a range that reaches the end of the text ends the scan | O(1) per candidate, no list |
| Masks | A range of prefixed formats only is ASCII without line breaks, so its mask is a slice of its first 8 characters plus a slice of a shared mark string; a private-key body skips ordinary characters natively and takes one slice of a shared "marks plus line break" string per line; address passwords and other mixed ranges count code points character by character | Θ(masked characters + lines), the order of the output |
### Memory
- Nothing is allocated before a node is returned: `test` creates no match array, memo slots are a preallocated integer array, loops use indexes instead of iterators, and candidate ranges live in local numbers.
- Each credential allocates only its result: the node, its `data` object, and the mask. A one-line mask is the concatenation of two slices; a multi-line mask adds one slice and one concatenation per line.
- Each instance keeps a reference to the last queried text until a query names another text.
### Trade-offs
- **Scanning is native, decisions are JavaScript**: in V8 a per-character JavaScript loop is slower than a native literal scan or native run measurement, so all work proportional to input length (finding starts, measuring runs, skipping key bodies) uses non-backtracking native patterns, and JavaScript makes a constant number of decisions per hit.
- **Pruning depends on the formats only**: starts come from format literals, not from anchors chosen by character frequency in natural text, so text with any character distribution pays the same linear cost.
- **Memo per text, not per query**: a memo inside one query cannot stop later queries from measuring the same characters again; keyed by text and owned by the instance, cleared on a new text, it costs one retained string reference.
- **Masks use shared-string slices**: faster than writing into a buffer and decoding it; a multi-line mask allocates in proportion to its lines, which is part of the output.
### Performance gate
[benchmarks/user-text](../../../../benchmarks/user-text/user-text.bench.client.ts) runs in the required `node 24 / benchmarks` lane. Its compiled worker uses the production UserText registry and every shipped matcher (session, file, folder, skill, command, whitespace, credential) under plain Node, in two processes: timing and allocation.
| Item | Content |
|---|---|
| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time |
| Timing | Each sample builds a fresh registry, document, and matcher instance and times evaluation only; each workload is measured with and without the credential matcher, keeping the fastest of several samples and the process CPU time over the same interval |
| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval |
| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character |
| Excluded | React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; time ratios, doubling ratios, and allocation budgets do not scale with machine speed. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset.
## Alternatives considered
- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure.
- **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure.
- **Scanning for rare characters first, then looking back for prefixes**: faster on ordinary text, but cost depends on character distribution, and text dense in `-` or `_` becomes clearly slower.
- **A pure JavaScript Aho–Corasick automaton**: allocation-free, but per-character table lookups carry a state dependency and are slower than the native literal scan, so the most common text, without credentials, gets slower.
- **A memo rebuilt per query**: with accepted tokens between digit-free `sk-` prefixes, every query's digit search reaches the end of the text, which grows quadratically.
- **Building private-key masks in one buffer with `TextDecoder`**: one string per block, but each decode's fixed cost exceeds shared-string slices.
- **`exec` for hits and `for…of` over memo slots**: they allocate match arrays and iterator results.
## Consequences
- The added cost on text without credentials is one native scan plus checks at literals; it cannot drop further without depending on character distribution.
- A new format adds its literal and a forward-only check to the format table; resume offsets and the hit table are derived. The literal-pattern unit test rejects quantifiers, and the benchmark rejects superlinear scans and extra allocation.
- The plugin creates and owns the matcher instance in `apply`; tests and the benchmark create one per sample and share no memo.
@@ -0,0 +1,67 @@
# Agent Note: 用户文本凭据识别的线性匹配与性能门禁
Status: implemented
[English](2026-10-11-user-text-credential-matching.md) | 中文
## 问题
[ui-secret-mask](../../../../packages/client/ui-secret-mask/README.zh.md) 在每条已发送用户文本上识别凭据。UserText 调度器在每个被采纳的节点之后再查询一次匹配器,一段文本的成本是它全部查询之和。大部分文本不含凭据,所以首要目标是让不含凭据的文本尽量少付成本;其次是含凭据文本的成本只与凭据个数和长度成正比;对抗输入必须保持线性。约束:正则不回溯;按前缀剪枝;一段文本的全部查询合计线性;返回节点之前不分配内存。
## 决定
### 算法:每部分的做法与复杂度
| 部分 | 做法 | 一段文本全部查询的代价 |
|---|---|---|
| 找格式起点 | 所有起始字面量(`sk-`、`sk_live_` 等、`AIza`、`ghp_` 等、`github_pat_`、`glpat-`、`npm_`、`hf_`、`AKIA`/`ASIA`、`LTAI`、`AKID`、`xox?-`、`whsec_`、`eyJ`、`-----BEGIN `、`://`)按共同前缀分组成一个只含字面量的交替正则,用 `test` 扫描 | Θ(n) 次原生比较,没有量词因而不回溯;相邻两次查询至多重读一个字面量 |
| 重叠的字面量 | 字面量的后缀若是另一个字面量的开头(`sk-`、`glpat-`、`xox?-` 的 `-` 与 `-----BEGIN `),扫描从该后缀处继续;续扫位置在模块加载时由字面量表算出 | 每个命中至多回退一个字符 |
| 识别命中的字面量 | 命中末尾两个码元查表;只有共享结尾的三组(`t_`、`e_`、`IA`)再用 `startsWith` 区分 | 每个命中 O(1) |
| 格式校验 | 单词边界、长度、数字位置、`.eyJ` 分隔用数值比较;私钥标签至多读 52 个字符,协议名至多回看 16 个字符;字符段用粘性 `[字符类]*` 原生测量,模式在第一个不属于该类的字符处停止,后面没有内容,不回溯 | 每个命中 O(1) 加字符段测量 |
| 字符段、下一个数字、下一行 `END` | 每个匹配器实例用一个 `Int32Array` 按文本记忆:`[A-Za-z0-9_-]` 四个槽(JWT 同时用三段),其余字符类各一个槽,另记下一个数字和下一行有效 `END` 的位置;这些是文本本身的事实,跨查询复用,查询换成另一段文本时清空 | 每类字符每个字符至多测量一次 |
| 地址密码 | `://` 在查询起点之前、密码在起点之后时,先确认起点后面紧跟 `:`、非空密码和 `@`,才回看用户名找 `://`;起点落在 `://` 中间时直接检查 | 回看至多 258 个字符,且只在 `:密码@` 紧跟起点时发生 |
| 合并候选 | 候选按起点顺序处理;地址密码在其 `://` 之后开始,延后到扫描越过它的起点,此前不会出现另一个 `://`(用户名和密码不含 `/`);重叠的候选并入最早的候选并使用其遮挡方式;范围到文本末尾即停止扫描 | 每个候选 O(1),不建列表 |
| 遮挡串 | 只由前缀格式组成的范围全是 ASCII 且没有换行,遮挡串是前 8 个字符的切片加一个共享星号串的切片;私钥正文用原生模式跳过普通字符,每行取一个"星号加换行"共享串的切片;地址密码和其他混合范围逐字符计数码点 | Θ(遮挡字符数 + 行数),与输出同阶 |
### 内存
- 返回节点之前不分配:`test` 不产生匹配数组,记忆槽是预先分配的整数数组,遍历用下标而不是迭代器,候选范围存在局部数值里。
- 每个凭据只分配结果:节点、`data` 对象和遮挡串。单行凭据的遮挡串是两个切片的拼接;多行遮挡每行再加一个切片和一次拼接。
- 每个实例保留最近一次查询的文本引用,直到查询另一段文本。
### 取舍
- **扫描交给原生正则,判断交给 JavaScript**:V8 中逐字符的 JavaScript 循环比原生字面量扫描和原生字符段测量慢;凡是与输入长度成正比的工作(找起点、量字符段、跳过私钥正文)都用不回溯的原生模式,JavaScript 只做每个命中常数次的判断。
- **只依赖格式本身的剪枝**:起点由格式字面量决定,不按字符在自然文本中的频率挑锚点,因此任何字符分布的文本都付同样的线性成本。
- **记忆按文本而不按查询**:一次查询内的记忆不能阻止后续查询重新测量同一段字符;以文本为键、由实例持有,换文本即清空,代价只是保留一个字符串引用。
- **遮挡串用共享串切片**:比先写入缓冲区再整体解码更快;多行遮挡的分配与行数成正比,仍属于输出本身。
### 性能门禁
[benchmarks/user-text](../../../../benchmarks/user-text/user-text.bench.client.ts) 在 `node 24 / benchmarks` 必跑 lane 中运行。编译后的 worker 使用生产的 UserText 注册表和全部已发布匹配器(会话、文件、文件夹、skill、命令、空白、凭据),在纯 Node 下分两个进程运行:计时与分配。
| 项 | 内容 |
|---|---|
| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 |
| 计时 | 每个样本新建注册表、文档和匹配器实例,只计求值;每个负载分别测有无凭据匹配器,取多个样本中最快的一个,并记录同一区间的进程 CPU 时间 |
| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC |
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
| 排除 | React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;耗时比、翻倍比和分配预算不随机器缩放。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。
## 考虑过的替代方案
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
- **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。
- **先找稀有字符再回看前缀**:普通文本扫描更快,但成本取决于字符分布,`-`、`_` 密集的文本明显变慢。
- **纯 JavaScript 的 Aho–Corasick 自动机**:可以零分配,但逐字符查表有状态依赖,比原生字面量扫描慢,最常见的无凭据文本反而变慢。
- **每次查询新建记忆**:被接受的 token 之间夹着无数字的 `sk-` 时,每次查询的数字查找都扫到文末,平方增长。
- **用 `TextDecoder` 从缓冲区一次生成私钥遮挡串**:每块只分配一个字符串,但每次解码的固定开销高于共享串切片。
- **用 `exec` 取命中、用 `for…of` 遍历记忆槽**:分别分配匹配数组和迭代器结果。
## 影响
- 无凭据文本的额外成本是一遍原生扫描加字面量处的校验;不依赖字符分布就无法再降。
- 新增格式要在格式表里加字面量和只向前的校验;续扫位置与命中识别表自动生成。字面量正则的单测拒绝量词,benchmark 拒绝超线性扫描和多余分配。
- 匹配器实例由插件在 `apply` 中创建并持有;测试和 benchmark 每个样本新建实例,不共享记忆。
@@ -37,6 +37,7 @@ When the outgoing change adds or changes a resource-owning or asynchronous test,
- **Expected-output placement:** a test whose selected recorded Session generation is replay input and expected persisted output belongs under top-level `snapshots/`, with `snapshot.yml` naming its shipped `dsh` profile and composition/header pin. Canonical parent files are `session[.vN].jsonl`, children are `session.<ordinal>[.vN].jsonl`, and the harness selects the highest generation per role. ARIA, geometry, generator, CLI, and unit expectations without that Session round trip stay beside their owning test under `tests/expected/`; do not place them in `snapshots/` or give them a `*.snapshot.ts` owner. Use the owning `test:expected`, `test:web`, or `test` lane.
- **Profile and configuration placement:** cross-package behavior of a shipped `dsh` profile belongs under `apps/cli/tests/profiles/`; a package-specific Loader composition belongs under that package's `tests/fixtures/`. User-facing optional overlays live under `apps/cli/config/examples/` and pair with a guide under `docs/user/`.
- **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke.
- **Repository test and script types:** `pnpm run build:artifacts` checks package projects only. Before claiming completion of TypeScript changes, run `pnpm run build:check` after the latest `build:artifacts`, or run `pnpm run build`; `build:check` reads the existing `build:artifacts` output and never rebuilds it.
- **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets.
Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook.
@@ -153,11 +153,15 @@ jobs:
retention-days: 7
# Blacksmith x64 CI builds use 2 vCPU for compilation, bundling, and wheel smokes.
# Release macOS builds use larger GitHub runners; ordinary CI and direct
# builder dispatches retain the standard native target matrix.
build:
needs: [plan, sdk-wheel]
name: ${{ matrix.target }}
runs-on: >-
${{ inputs.ci && !inputs.release && matrix.target == 'node24-linux-x64'
${{ inputs.release && matrix.target == 'node24-macos-x64' && 'macos-15-large'
|| inputs.release && matrix.target == 'node24-macos-arm64' && 'macos-15-xlarge'
|| inputs.ci && !inputs.release && matrix.target == 'node24-linux-x64'
&& vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-2vcpu-ubuntu-2404'
|| inputs.ci && !inputs.release && matrix.target == 'node24-win-x64'
&& vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025'
@@ -262,7 +266,7 @@ jobs:
DSH_BUILD_CLIENT_PROFILE: official
run: >-
pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }}
${{ inputs.ci && github.event_name == 'pull_request' && '--artifacts-only' || '' }}
${{ inputs.ci && github.event_name == 'pull_request' && '--build-artifacts' || '' }}
- name: Resolve platform outputs (POSIX)
id: runtime-posix
@@ -71,7 +71,7 @@ jobs:
# build:preview packs the image through the packer's installed bin
# (lib/bin.js), so neither half exists before the artifact build runs.
- name: Build workspace
run: pnpm run build --artifacts-only
run: pnpm run build:artifacts
- name: Build the preview page and pack the VFS image
env:
+1 -1
View File
@@ -107,7 +107,7 @@ jobs:
# the built artifact under plain Node, resolving plugins through real package
# exports — the shape a real consumer runs. That requires a prior build.
- name: Build (lib for the e2e example bins)
run: ${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }}
run: ${{ github.event_name == 'pull_request' && 'pnpm run build:artifacts --profile official' || 'pnpm run build:official' }}
- name: Prepare Office runtime
run: pnpm run prepare:primary-runtime --target linux-x64 --output .cache/e2e-office
+1 -1
View File
@@ -146,7 +146,7 @@ jobs:
run: pnpm run release:verify --family dsh
- name: Build
run: ${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }}
run: ${{ github.event_name == 'pull_request' && 'pnpm run build:artifacts --profile official' || 'pnpm run build:official' }}
# Concurrency here is rehearsal-only: the credentialed publish workflows
# invoke release:pack without the flag and keep the strictly serial path.
+43 -27
View File
@@ -1,27 +1,25 @@
name: Windows installer (unsigned)
name: Windows installer
on:
workflow_dispatch:
inputs:
build_version:
description: 'Build version: auto generates a version; otherwise enter a full version'
type: string
default: auto
required: true
deployment:
description: Update policy deployment
preset:
description: 'Machine preset (configured on the packaging runner)'
type: choice
options: [test, production]
default: test
required: true
policy_origin:
description: 'Required update policy HTTPS origin (no path)'
type: string
signing:
description: 'unsigned: test installer; signed: use the configured EV hardware token'
type: choice
options: [unsigned, signed]
default: unsigned
required: true
login_origins:
description: 'Required for test: comma-separated allowed login HTTPS origins'
build_version:
description: 'Version: auto recommended; or checkout product version / e.g. 0.2.1-alpha.1.20261009.1 (prefix must match checkout)'
type: string
required: false
default: auto
required: true
permissions:
contents: read
@@ -39,7 +37,20 @@ jobs:
shell: pwsh
env:
DSH_TELEMETRY_DISABLED: '1'
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
PACKAGE_SIGNING: ${{ inputs.signing }}
steps:
- name: Reset packaging checkout
working-directory: ${{ runner.temp }}
run: |
$workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE)
$parent = [IO.Path]::GetFullPath($env:RUNNER_WORKSPACE)
if ([IO.Path]::GetDirectoryName($workspace) -ne $parent) { throw 'Checkout must be a direct child of RUNNER_WORKSPACE' }
for ($item = [IO.DirectoryInfo]::new($workspace); $null -ne $item; $item = $item.Parent) {
if ($item.Exists -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Checkout ancestors cannot be links' }
}
node -e "const fs = require('node:fs'); fs.rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 }); fs.mkdirSync(process.argv[1])" $workspace
if ($LASTEXITCODE -ne 0) { throw 'Checkout cleanup failed' }
- uses: actions/[email protected]
id: checkout
with:
@@ -55,25 +66,26 @@ jobs:
run: |
pnpm install --frozen-lockfile
exit $LASTEXITCODE
- name: Validate inputs and prepare public package settings
- name: Validate preset and resolve build version
id: settings
env:
PACKAGE_BUILD_VERSION: ${{ inputs.build_version }}
PACKAGE_DEPLOYMENT: ${{ inputs.deployment }}
PACKAGE_POLICY_ORIGIN: ${{ inputs.policy_origin }}
PACKAGE_LOGIN_ORIGINS: ${{ inputs.login_origins }}
run: node apps/desktop/scripts/prepare-windows-ci.mjs
- name: Check packaging configuration and toolchain
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: |
pnpm run package:desktop:win:x64:unsigned --check --build-version "$env:PACKAGE_VERSION"
$packageArgs = @('run', 'package:desktop:win:x64', '--check', '--build-version', $env:PACKAGE_VERSION)
if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }
pnpm @packageArgs
exit $LASTEXITCODE
- name: Build installer and verify packaged runtime
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: |
pnpm run package:desktop:win:x64:unsigned --build-version "$env:PACKAGE_VERSION"
$packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION)
if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }
pnpm @packageArgs
exit $LASTEXITCODE
- name: Retain packaging diagnostics
if: always() && steps.checkout.outcome == 'success'
@@ -84,7 +96,8 @@ jobs:
path: apps/desktop/.desktop-build/packaging-runs/
if-no-files-found: warn
retention-days: 14
- name: Retain verified installer
- name: Retain verified unsigned installer
if: success() && inputs.signing == 'unsigned'
uses: actions/[email protected]
with:
name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
@@ -92,9 +105,12 @@ jobs:
path: apps/desktop/.desktop-build/targets/win-x64/unsigned-artifacts/*.exe
if-no-files-found: error
retention-days: 14
- name: Remove generated package settings
if: always() && steps.checkout.outcome == 'success'
run: |
if (Test-Path -LiteralPath apps/desktop/.env.windows) {
Remove-Item -LiteralPath apps/desktop/.env.windows
}
- name: Retain verified signed installer
if: success() && inputs.signing == 'signed'
uses: actions/[email protected]
with:
name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: apps/desktop/.desktop-build/targets/win-x64/artifacts/*.exe
if-no-files-found: error
retention-days: 14
+5 -2
View File
@@ -92,7 +92,9 @@ pnpm run test:snapshot:record # re-record expected outputs (needs key)
pnpm run typecheck
pnpm run lint
pnpm run duplication # cross-file TypeScript clone detection
pnpm run build # tsc emits lib/types, tsdown bundles runtime
pnpm run build # build:artifacts + build:check, concurrently
pnpm run build:artifacts # artifacts: tsc emits lib/types, tsdown bundles runtime, Web
pnpm run build:check # typecheck aggregate tests/scripts against current build:artifacts output
pnpm run hygiene # publint + workspace/package/dependency checks + NodeNext consumer check
pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal
pnpm run doc-sync # documentation gates (scripts/run-gates.ts)
@@ -100,7 +102,7 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg
pnpm run website:build # VitePress build (doubles as dead-link check)
pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY)
pnpm run demo:ptc -- "task" # headless PTC mode run (needs key)
pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build
pnpm run dev:web | dev:desktop # build:artifacts, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build
make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options
```
@@ -115,6 +117,7 @@ Before pushing, follow [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/
- Match evidence to the surface: focused behavior tests, model/user-output snapshots, `doc-sync` for docs, built smokes for published paths, and real-API e2e for providers.
- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change.
- `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)).
- Develop with `build:artifacts`; before claiming completion, run `build:check` after the latest `build:artifacts`, or `build` ([layout](docs/development.md#typescript-project-layout)).
- **Web browser automation and GIF recording:** launch with `pnpm dsh web --patch apps/web/tests/pin-browse-picker.overlay.yml` to use the [in-page directory picker](apps/web/tests/pin-browse-picker.overlay.yml); omit this override only when testing native picker behavior explicitly.
## Secrets / .env
+1 -1
View File
@@ -5,7 +5,7 @@
DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Run `pnpm run gen-third-party-notices` after changing one of its inputs, or `pnpm run verify-third-party-notices` for the standalone check.
The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock).
@@ -293,9 +293,9 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
await ctx.plugin(CordisInspectProviders)
await ctx.plugin(ToolCordis)
const agent = {
ctx,
id: SessionId('ptc-cordis'),
session: ctx.sessions.create(SessionId('ptc-cordis'), { meta: { cwd: process.cwd() } }),
ctx,
} as Agent
const value = completion(await runCode(ctx, `
+10 -10
View File
@@ -15,8 +15,8 @@
en: 069fdfd72d2ea26a
zh: 76ae549d43c99bb4
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
en: cc2d4a2d5aecfaa0
zh: 2a1d612560f6d95d
en: ef26e0055f9401ee
zh: b358a68cf30ed720
/deepseek-harness-desktop/bundled-command-runtime:
en: 09f0b75af9f0ed14
zh: 60bbc2e67d5e87a8
@@ -42,8 +42,8 @@
en: 4cd8a8da6977928d
zh: 9f624855a2edac37
/deepseek-harness-desktop/package/release-versions:
en: d938302f92fc3a1a
zh: 29df50bc8791baa7
en: fdc1fadc506d1087
zh: 46d5cb8a60ddf7cf
/deepseek-harness-desktop/package/local-macos-dmg-without-release-signing:
en: 57869a5e3885f843
zh: 7cfc01b2dcbebd78
@@ -51,20 +51,20 @@
en: 5f05072ec82cc62d
zh: a3d63b58d1ef2db7
/deepseek-harness-desktop/package/runtime-file-selection:
en: 8b4cca1467f0bea6
zh: fa3487fc56a53fc4
en: 2f957ee7e7a4e6e2
zh: a3b01674d54bec6a
/deepseek-harness-desktop/package/upload-updates:
en: f48baa680e841b0d
zh: 25ded1aeaac6dad1
/deepseek-harness-desktop/package/unsigned-windows-test-installer:
en: fb6de542360dbae7
zh: 0e17d7765fb10c48
en: 2e30de3155c32344
zh: be88a9d7f8682e75
/deepseek-harness-desktop/package/windows-installer-interface:
en: 1fc1db8f54475c8f
zh: dd834ed206070396
/deepseek-harness-desktop/package/windows-ev-signing:
en: 64836513eb7eda6e
zh: 6ebb9184a7aa51de
en: 5c12ccf2f4482606
zh: efea2cb823bda62f
/deepseek-harness-desktop/package/desktop-runtime-preparation:
en: 9a71990bee9867a7
zh: 76c5c1fdff6e6908
+13 -7
View File
@@ -70,7 +70,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers
The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.
Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. macOS signs the versioned Python executable once, retaining the [two interpreter aliases](../../packages/skill/tool-workspace-dependencies/README.md) in external resources and installed copies. POSIX native-target checks require relative interpreter aliases. All native-target checks exercise command names, child execution and a pip-enabled venv, verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
The shared Desktop/SDK runtime builder omits directories named `tests` beneath NumPy and pandas before copying or signing the payload, and removes their rows from each distribution’s `RECORD`. It retains `numpy.testing`, `pandas.testing`, `pandas._testing`, native extensions and licenses. Complete upstream suites are unavailable; native checks verify the retained testing APIs, absence of test directories and existence of recorded files.
@@ -217,9 +217,9 @@ A production release publishes the product version and takes no `--build-version
Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier.
Except for local unsigned macOS builds, packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging.
Except for Windows packaging with a selected machine preset and local unsigned macOS builds, packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the selected configuration files, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. Windows CI reads machine presets as described below.
Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. `--build-version auto` does contact the destination bucket, including under `--check`. Run the same checks separately:
Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Without a Windows machine preset, `--build-version auto` contacts the destination bucket, including under `--check`. Run the same checks separately:
```sh
pnpm --dir apps/desktop run check:package
@@ -274,7 +274,7 @@ The served `time` is the real packing time, so pnpm 11's default 24-hour `minimu
### Runtime file selection
Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. Non-Windows targets also omit `node-pty/third_party/conpty`; Windows targets retain that directory. It preserves runtime JavaScript, target native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory and Yarn development metadata, selected native compiler outputs, and node-pty prebuilds for other platforms. Non-Windows targets also omit `node-pty/third_party/conpty`; Windows targets retain that directory. It preserves runtime JavaScript, target native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
The [Electron shell file list](scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](tests/shell-file-selection.spec.ts) check retained bytes and exclusions.
@@ -364,9 +364,15 @@ The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mand
Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths.
The [manual Windows workflow](../../.github/workflows/windows-package.yml) selects a self-hosted Windows x64 runner with the dedicated `dsh-win-package-trial` label and calls the same unsigned command. Provision PowerShell 7 and the tools listed above before starting the runner. It requires the policy deployment and HTTPS origin; test deployments also require comma-separated login origins. These public inputs generate a temporary `.env.windows` without signing or upload credentials. `build_version` defaults to `auto`, using the CI identifier described in [Release versions](#release-versions). An explicit version uses the existing product-version validator. The resolved version appears in the run summary before packaging; manifests remain unchanged.
The [manual Windows workflow](../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `unsigned` (default) or `signed`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged.
After this workflow reaches the default branch, select it in Actions, choose the source branch, fill the inputs and click Run workflow. It has no push or PR trigger and leaves existing automatic CI unchanged. Packaging logs and successful EXE artifacts are retained for 14 days; installation, signing and publication are separate operations. Run it once from the default branch and inspect the retained logs and installer before relying on Actions packaging. Checkout cleans the runner-owned source directory, including ignored files; never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run can remain pending, and a newer dispatch replaces that pending run. Diagnostics are uploaded only after a successful clean checkout, and generated settings are removed even when preparation fails after writing them.
Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts.
The shared packaging runner assumes trusted internal-team code and operators. Under this assumption, credential and device isolation are deferred and the residual risk is accepted (tracked in #5973); signed/master-only access is not enforced. Unsigned mode skips signing-file loading but cannot prevent branch code running as the same Windows account from reading those files or accessing the token. SafeNet Single Logon may reuse authentication. Direct device calls can bypass the signing scripts and exhaust PIN retries; the persistent failure interlock only protects calls through those scripts. Cross-account token isolation and remaining retry counts are unverified. Reassess runner/account/device isolation before expanding access, executing untrusted contributions, changing runner sharing, or after suspicious activity.
Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/<preset>.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned and preparation-only presets never read the signing file. Cache maintenance also reads only public preset settings. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt.
Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root <absolute-machine-root>` to prepare private `packaging/{presets,secrets,certificates}` and `cache/windows-signatures` directories and copy templates without overwriting existing files. Add `-CheckOnly` to inspect without writes. Fill the [preset templates](config/windows-packaging/test.env.example) and [signing template](config/windows-packaging/windows-signing.env.example) on the machine. Run `start-windows-packaging-runner.ps1 -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging -CheckOnly` from the same scripts directory to inspect PowerShell 7.6.6 under the runner's `tools` directory, Node, Python, Git, tar and Visual C++ tools; packaging `--check` performs the remaining toolchain validation. Omit `-CheckOnly` to start the existing runner with an explicit config root and scrubbed inherited environment. Registration and tool installation are separate; these scripts never create tokens, alter system security settings or clear signing failure records. Use the signing cache maintenance command below to migrate existing entries before changing the preset's cache path.
<a id="windows-uninstall-rules"></a>
@@ -422,7 +428,7 @@ Insert and unlock the token before packaging. The electron-builder hook passes e
Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md).
The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure.
The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` or machine-owned `secrets/windows-signing.env` stores the PIN in plaintext and needs restricted file access. CI reads the machine-owned file outside the checkout and retains it across jobs. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure.
Create a runnable application directory instead of an installer by using the matching `:dir` command, such as:
+13 -7
View File
@@ -72,7 +72,7 @@ Desktop 展示精简的 `office` 路由及可具名调用的创作/操作子
Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。
Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。macOS 只对版本化 Python 可执行文件签名一次,在外部资源及安装副本中保留[两个解释器别名](../../packages/skill/tool-workspace-dependencies/README.zh.md)。POSIX 本机目标检查要求解释器别名为相对链接。所有本机目标检查验证命令名、子进程执行及包含 pip 的 venv,并在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
Desktop/SDK 共享运行时构建器在复制或签名产物前,裁剪 NumPy 和 pandas 下名为 `tests` 的目录,并移除各分发包 `RECORD` 中对应的记录。它保留 `numpy.testing`、`pandas.testing`、`pandas._testing`、原生扩展和许可证。完整上游测试套件不可用;本机检查验证保留的 testing API、测试目录已移除,以及清单所列文件存在。
@@ -219,9 +219,9 @@ production 发布使用产品版本本身,不传 `--build-version`。其上传
版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。
除本地 macOS 未签名构建外,打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。
除选择机器 preset 的 Windows 打包和本地 macOS 未签名构建外,打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从选定的配置文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。Windows CI 读取下文说明的机器 preset。
每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查:
每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。未选择 Windows 机器 preset 时,`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查:
```sh
pnpm --dir apps/desktop run check:package
@@ -276,7 +276,7 @@ pnpm run dev:bundle-registry
### 运行时文件筛选
Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。非 Windows 目标还会排除 `node-pty/third_party/conpty`;Windows 目标保留该目录。它保留运行时 JavaScript、目标平台的原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。
Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录及 Yarn 开发元数据、选定的原生编译器输出和其他平台的 node-pty 预构建文件。非 Windows 目标还会排除 `node-pty/third_party/conpty`;Windows 目标保留该目录。它保留运行时 JavaScript、目标平台的原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。
[Electron 外壳文件清单](scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。
@@ -366,9 +366,15 @@ pnpm run package:desktop:win:x64:unsigned
先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。
[Windows 手动工作流](../../.github/workflows/windows-package.yml) 选择带有专用标签 `dsh-win-package-trial` 的自托管 Windows x64 runner,并调用同一个未签名命令。启动 runner 前需准备 PowerShell 7 和上述工具。必须填写策略部署环境和 HTTPS origin;测试部署还需填写逗号分隔的登录 origin。这些公开输入生成临时 `.env.windows`,不包含签名或上传凭据。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中说明的 CI 标识。显式版本复用现有产品版本校验器。解析后的版本在打包前显示于运行摘要,manifest 保持不变。
[Windows 手动工作流](../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `unsigned`(默认)或 `signed`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。
工作流进入默认分支后,在 Actions 中选择它,选择源码分支、填写输入并点击 Run workflow。它不监听 push 或 PR,不改变现有自动 CI。打包日志和成功生成的 EXE 产物保留 14 天;安装、签名和发布是独立操作。依赖 Actions 打包前,先从默认分支运行一次,并检查保留的日志和安装包。Checkout 会清理 runner 专用源码目录,包括被忽略的文件;不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新的手动触发会替换该待运行任务。仅在成功完成干净检出后上传诊断日志;即使配置准备在写入后失败,也会清理生成的配置。
在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。
共享打包 runner 以内部团队代码和操作人员可信为前提。在此前提下,凭据和设备隔离暂缓实施,剩余风险按已知风险接受(由 #5973 跟踪);尚未强制限制仅 signed/master 可访问。未签名模式跳过签名文件加载,但无法阻止同一 Windows 账户下的分支代码读取这些文件或访问 Token。SafeNet Single Logon 可能复用认证状态。直接调用设备可以绕过签名脚本并耗尽 PIN 重试次数;持久失败 interlock 只保护经过这些脚本的调用。跨账户 Token 隔离和剩余重试次数尚未验证。扩大访问范围、执行不可信贡献、改变 runner 共享方式前,或出现可疑活动后,重新评估 runner、账户和设备隔离。
本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/<preset>.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名和仅准备 preset 不读取签名文件。缓存维护也只读取公开 preset 配置。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。
运行 `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root <absolute-machine-root>` 创建私有 `packaging/{presets,secrets,certificates}` 和 `cache/windows-signatures` 目录并复制模板,不覆盖现有文件。添加 `-CheckOnly` 只检查不写入。在机器上填写 [preset 模板](config/windows-packaging/test.env.example)和[签名模板](config/windows-packaging/windows-signing.env.example)。从同一 scripts 目录运行 `start-windows-packaging-runner.ps1 -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging -CheckOnly`,检查 runner 的 `tools` 目录下 PowerShell 7.6.6、Node、Python、Git、tar 和 Visual C++ 工具;打包 `--check` 完成其余工具链检查。省略 `-CheckOnly`,以显式配置目录和清理后的继承环境启动现有 runner。注册和工具安装单独进行;这些脚本不创建 token、不改系统安全设置、不清除签名失败记录。修改 preset 缓存路径前,使用下文签名缓存维护命令迁移已有条目。
<a id="windows-uninstall-rules"></a>
@@ -424,7 +430,7 @@ pnpm run package:desktop:win:x64
时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。
PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。
PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 或机器管理的 `secrets/windows-signing.env` 明文保存 PIN,应限制文件访问权限。CI 读取 checkout 外的机器签名文件,并在任务结束后保留。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。
使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如:
@@ -0,0 +1,7 @@
# Copy to production.env in the same machine configuration directory and fill in the values.
DSH_DESKTOP_APP_ID=com.deepseek.harness
DSH_DESKTOP_AUTO_UPDATE_ENV=production
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}'
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
@@ -0,0 +1,13 @@
# Copy to test.env in the same machine configuration directory and fill in the values.
# Public test configuration. No PIN or upload credentials belong here.
DSH_DESKTOP_APP_ID=com.deepseek.harness
DSH_DESKTOP_AUTO_UPDATE_ENV=test
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
# Replace login.example.com with the real login origin before packaging.
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}'
# Required for signed test packages; retain the release ID for an upgrade test batch.
DOWNLOAD_TEST_ORIGIN=
DOWNLOAD_TEST_RELEASE_ID=
# Optional absolute private cache directory. Omit to retain the account default cache.
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
@@ -0,0 +1,7 @@
# Copy to windows-signing.env in the same machine configuration directory and fill in the values.
# Restrict this file to the runner account, SYSTEM and administrators.
# File paths resolve relative to this file; use the same account as local signing.
DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/windows.cer
DSH_DESKTOP_WINDOWS_SIGNTOOL=
DSH_DESKTOP_WINDOWS_KEY_CONTAINER=
DSH_DESKTOP_WINDOWS_TOKEN_PIN=
-2
View File
@@ -75,8 +75,6 @@
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"vite": "^6.4.3",
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
@@ -8,16 +8,18 @@
export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv
/**
* Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
* Read a Windows machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
* @param platform Target platform.
* @param environment Parent environment, retained only for unrelated build tools.
* @param appRoot Desktop application directory; relative credential paths resolve here.
* @param options Unsigned and preparation-only presets never read signing credentials.
* @returns Isolated environment with file-owned release settings.
*/
export function loadDesktopPackageEnvironment(
platform: 'win32' | 'darwin',
environment?: NodeJS.ProcessEnv,
appRoot?: string,
options?: { unsigned?: boolean, prepareOnly?: boolean },
): NodeJS.ProcessEnv
/**
@@ -1,7 +1,7 @@
/** Select local test defaults or platform release settings without changing the caller's environment. */
import { accessSync, constants, readFileSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseEnv } from 'node:util'
import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs'
@@ -33,14 +33,46 @@ export function localMacOSPackageEnvironment(environment = process.env) {
}
/**
* Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
* Read a Windows machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
* @param {'win32' | 'darwin'} platform Target platform.
* @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools.
* @param {string} appRoot Desktop application directory; relative credential paths resolve here.
* @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Unsigned and preparation-only presets never read signing credentials.
* @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings.
*/
export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT) {
const path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos')
export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) {
const preset = environment.DSH_PACKAGING_PRESET
let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos')
if (preset !== undefined) {
if (platform !== 'win32') throw new Error('desktop package: presets support Windows only')
if (preset !== 'test' && preset !== 'production') throw new Error('desktop package: preset must be test or production')
const root = environment.DSH_PACKAGING_CONFIG_DIR
if (!root || !isAbsolute(root)) throw new Error('desktop package: DSH_PACKAGING_CONFIG_DIR must be an absolute directory')
path = join(root, 'presets', `${preset}.env`)
}
const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING
const settings = readSettings(path, name => preset === undefined
? SHARED_SETTING.test(name) || platformSetting.test(name)
: /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN)|WINDOWS_SIGNATURE_CACHE_(?:DIR|CONCURRENCY))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name))
if (preset !== undefined) {
if (settings.DSH_DESKTOP_AUTO_UPDATE_ENV !== preset) throw new Error('desktop package: preset deployment must match its name')
if (!options.unsigned && !options.prepareOnly) Object.assign(settings, readSettings(join(dirname(dirname(path)), 'secrets', 'windows-signing.env'),
name => /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u.test(name)))
}
return {
...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))),
...settings,
...(preset === undefined ? {} : { DSH_PACKAGING_PRESET: preset }),
}
}
/**
* Read an explicitly selected dotenv without exposing credential-bearing parser errors.
* @param {string} path Selected file; relative file settings resolve beside it.
* @param {(name: string) => boolean} accepts Whether this file may declare a setting.
* @returns {NodeJS.ProcessEnv} Validated file settings.
*/
function readSettings(path, accepts) {
let contents
try {
contents = readFileSync(path, 'utf8')
@@ -56,9 +88,8 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
// Parser diagnostics can contain credential-bearing input.
throw new Error(`desktop package: invalid dotenv syntax in ${path}`)
}
const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING
for (const name of Object.keys(settings)) {
if (!SHARED_SETTING.test(name) && !platformSetting.test(name)) {
if (!accepts(name)) {
throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`)
}
if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`)
@@ -66,10 +97,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
for (const name of FILE_SETTINGS) {
if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim())
}
return {
...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))),
...settings,
}
return settings
}
function requireReadableFile(environment, name) {
+1 -1
View File
@@ -93,7 +93,7 @@ async function launchElectron(): Promise<void> {
async function main(): Promise<void> {
const { values } = parseArgs({ options: { 'skip-build': { type: 'boolean', default: false } } })
if (!values['skip-build']) {
await runPackageScript('build', REPOSITORY_ROOT)
await runPackageScript('build:artifacts', REPOSITORY_ROOT)
await runPackageScript('build', APP_ROOT)
}
for (const path of [
+20 -4
View File
@@ -1,8 +1,9 @@
/** Sign final native runtime files before the enclosing Desktop application is signed. */
import { createHash } from 'node:crypto'
import { closeSync, openSync, readSync } from 'node:fs'
import { join } from 'node:path'
import { closeSync, openSync, readdirSync, readSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { primaryRuntimePythonAliases, readPrimaryRuntime } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts'
import { inventoryDesktopRuntime } from '../src/runtime-tree.ts'
import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
import { cachedMacOSSignature, pruneMacOSSignatureCache } from './macos-signature-cache.ts'
@@ -19,19 +20,34 @@ function magic(path: string): string {
} finally { closeSync(descriptor) }
}
async function primaryRuntimeFiles(root: string): Promise<string[]> {
const aliases = await primaryRuntimePythonAliases(root, await readPrimaryRuntime(root))
const files: string[] = []
for (const entry of readdirSync(root, { recursive: true, withFileTypes: true })) {
const path = relative(root, join(entry.parentPath, entry.name)).split(sep).join('/')
if (entry.isDirectory() || (entry.isSymbolicLink() && aliases.has(path))) continue
if (!entry.isFile()) throw new Error(`desktop runtime: unsupported filesystem entry ${path}`)
files.push(path)
}
return files.sort()
}
/**
* Sign and verify every materialized Mach-O file, awaiting all signers on failure.
* @param root - Self-contained production runtime without symlinks.
* @param root - Self-contained production runtime.
* @param appId - Release application identifier.
* @param expected - Required signing identity.
* @param arch - Target runtime architecture, independent of the signing host.
* @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe.
* @param kind - Primary payloads admit validated Python aliases; Desktop application runtimes reject every link.
* @returns Number of signed native files.
*/
export async function signMacOSRuntime(
root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string,
kind: 'desktop' | 'primary' = 'desktop',
): Promise<number> {
const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => MACH_O_MAGICS.has(magic(join(root, path))))
const inventory = kind === 'primary' ? await primaryRuntimeFiles(root) : inventoryDesktopRuntime(root).map(file => file.path)
const files = inventory.filter(path => MACH_O_MAGICS.has(magic(join(root, path))))
const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '')
let hits = 0
let misses = 0
@@ -11,7 +11,7 @@ const { values } = parseArgs({ options: {
if ([values.from !== undefined, values.usage === true, values.clear === true].filter(Boolean).length !== 1) {
throw new Error('Specify exactly one of --from <old-cache>, --usage or --clear')
}
const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32')
const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32', process.env, undefined, { unsigned: true })
: { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.directory }
const root = resolveWindowsSignatureCacheDirectory(environment)
const record = event => process.stdout.write(`${JSON.stringify(event)}\n`)
+7 -3
View File
@@ -322,7 +322,9 @@ async function resolveRequestedBuildVersion(
if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion)
const paths = desktopTargetBuildPaths(invocation.target.name)
return suggestDesktopBuildVersion({
productVersion, target: invocation.target.name, environment,
productVersion, target: invocation.target.name,
// Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts.
environment: environment.DSH_PACKAGING_PRESET === undefined ? environment : {},
// Unsigned builds land beside the signed output, so numbering has to read the directory this run writes.
artifactsRoot: invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts,
})
@@ -333,9 +335,11 @@ async function main(): Promise<void> {
const { target } = invocation
const environment = target.platform === 'darwin' && invocation.unsigned
? localMacOSPackageEnvironment()
: loadDesktopPackageEnvironment(target.platform)
: loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, {
unsigned: invocation.unsigned, prepareOnly: invocation.prepareOnly,
})
const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')
// Release settings come from the target dotenv file alone, so the version this run publishes is an
// Release settings come from the selected configuration files, so the version this run publishes is an
// argument; the environment variable below only carries it to the child processes that build.
const buildVersion = await resolveRequestedBuildVersion(invocation, productVersion, environment)
environment[DESKTOP_BUILD_VERSION_ENV] = buildVersion
+1 -1
View File
@@ -158,7 +158,7 @@ async function main(): Promise<void> {
}
if (process.platform === 'darwin' && process.env.DSH_DESKTOP_UNSIGNED !== '1') {
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache')))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache')))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'), 'primary'))
}
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')))
@@ -1,14 +1,10 @@
/** Public settings prepared for a manual Windows package run. */
export interface WindowsCiSettings {
version: string
settings: string
}
/** Resolve the version used by the manual Windows packaging workflow. */
/**
* Validate manual inputs without local credentials or release storage.
* @param environment Manual inputs and GitHub run identity.
* Resolve an explicit version or a UTC date/run/attempt identifier without release storage access.
* @param environment Manual version and GitHub run identity.
* @param productVersion Version declared by the selected checkout.
* @param now Time used for the UTC date segment.
* @returns Validated version and public dotenv content.
* @returns Validated build version.
*/
export function windowsCiSettings(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): WindowsCiSettings
export function windowsCiVersion(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): string
+30 -32
View File
@@ -1,54 +1,52 @@
/** Prepare public Windows packaging settings and a version for a manual GitHub run. */
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
/** Resolve a manual Windows build version and validate the selected machine preset. */
import { appendFileSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs'
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
/**
* Validate manual inputs without reading local credentials or querying release storage.
* @param {NodeJS.ProcessEnv} environment Manual inputs and GitHub run identity.
* Resolve an explicit version or a UTC date/run/attempt identifier without release storage access.
* @param {NodeJS.ProcessEnv} environment Manual version and GitHub run identity.
* @param {string} productVersion Version declared by the selected checkout.
* @param {Date} now Time used for the UTC date segment.
* @returns {{ version: string, settings: string }} Validated version and public dotenv content.
* @returns {string} Validated build version.
*/
export function windowsCiSettings(environment, productVersion, now = new Date()) {
const deployment = environment.PACKAGE_DEPLOYMENT
if (deployment !== 'test' && deployment !== 'production') throw new Error('Select test or production deployment')
const loginOrigins = (environment.PACKAGE_LOGIN_ORIGINS ?? '').split(',').map(value => value.trim()).filter(Boolean)
if (deployment === 'production' && loginOrigins.length > 0) throw new Error('Production must not supply login origins')
const originKey = deployment === 'test' ? 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN' : 'DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN'
const config = deployment === 'test' ? { allowedAuthOrigins: loginOrigins } : {}
const policy = resolveDesktopPolicyEnvironment({
DSH_DESKTOP_AUTO_UPDATE_ENV: deployment,
[originKey]: environment.PACKAGE_POLICY_ORIGIN,
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify(config),
})
export function windowsCiVersion(environment, productVersion, now = new Date()) {
const requested = environment.PACKAGE_BUILD_VERSION?.trim()
if (!requested) throw new Error('Build version is required; use auto or a full version')
const date = now.toISOString().slice(0, 10).replaceAll('-', '')
const example = `${desktopBuildVersionPrefix(productVersion)}${date}.1`
if (!requested) throw new Error(`Build version is required: use auto, ${productVersion}, or e.g. ${example}`)
let candidate = requested
if (requested === 'auto') {
for (const name of ['GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT']) {
if (!/^[1-9]\d*$/u.test(environment[name] ?? '')) throw new Error(`${name} must be a positive integer`)
}
const date = now.toISOString().slice(0, 10).replaceAll('-', '')
candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}`
}
const version = validateDesktopBuildVersion(candidate, productVersion)
const settings = [
'DSH_DESKTOP_APP_ID=com.deepseek.harness',
`DSH_DESKTOP_AUTO_UPDATE_ENV=${deployment}`,
`${originKey}=${policy.origin}`,
`DSH_DESKTOP_MANDATORY_UPDATE_CONFIG=${JSON.stringify(config)}`,
].join('\n') + '\n'
return { version, settings }
try {
return validateDesktopBuildVersion(candidate, productVersion)
} catch {
throw new Error(`Invalid build version for this checkout: use auto, ${productVersion}, or e.g. ${example}`)
}
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
const appRoot = resolve(import.meta.dirname, '..')
const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8'))
const result = windowsCiSettings(process.env, product.version)
const version = windowsCiVersion(process.env, product.version)
const mode = process.env.PACKAGE_SIGNING
if (mode !== 'unsigned' && mode !== 'signed') throw new Error('Select unsigned or signed packaging')
if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset')
const environment = loadDesktopPackageEnvironment('win32', process.env, appRoot, { unsigned: mode === 'unsigned' })
// Machine-owned secrets are not automatically known to the Actions log masker.
for (const [name, value] of Object.entries(environment)) {
if (/TOKEN|PASSWORD|SECRET/iu.test(name) && value) {
const escaped = value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')
process.stdout.write(`::add-mask::${escaped}\n`)
}
}
validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' })
if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions')
writeFileSync(resolve(appRoot, '.env.windows'), result.settings, { flag: 'wx' })
appendFileSync(process.env.GITHUB_OUTPUT, `version=${result.version}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 unsigned installer\n\nVersion: ${result.version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nDeployment: ${process.env.PACKAGE_DEPLOYMENT}\n`)
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
}
@@ -34,6 +34,7 @@ export function desktopRuntimeFileExclusion(
return 'fs-ext build configuration'
}
if (name === '@mixmark-io/domino' && (entry === 'test' || entry.startsWith('test/'))) return 'Domino test fixtures'
if (name === '@mixmark-io/domino' && (entry === '.yarn' || entry.startsWith('.yarn/'))) return 'Domino development tooling'
if (name === 'node-pty' && target.platform !== 'win32' && /^third_party\/conpty(?:\/|$)/u.test(entry)) {
return 'node-pty Windows ConPTY sources'
}
@@ -0,0 +1,49 @@
# Prepare or inspect machine-owned Windows packaging directories without reading secret values.
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$Root,
[switch]$CheckOnly
)
$ErrorActionPreference = 'Stop'
if ($Root -notmatch '^[A-Za-z]:[\\/]') { throw 'Root must be an absolute local drive directory' }
$rootPath = [IO.Path]::GetFullPath($Root)
$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($rootPath))
if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'Packaging root requires a fixed local drive' }
for ($ancestor = [IO.DirectoryInfo]::new($rootPath); $null -ne $ancestor; $ancestor = $ancestor.Parent) {
if ($ancestor.Exists -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Packaging root cannot contain linked directories' }
}
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().User
$allowed = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544')
foreach ($relative in @('packaging', 'packaging/presets', 'packaging/secrets', 'packaging/certificates', 'cache', 'cache/windows-signatures')) {
$path = Join-Path $rootPath $relative
if (-not (Test-Path -LiteralPath $path)) {
if ($CheckOnly) { throw "Missing directory: $path" }
$null = New-Item -ItemType Directory -Path $path
$acl = [Security.AccessControl.DirectorySecurity]::new()
$acl.SetOwner($identity)
$acl.SetAccessRuleProtection($true, $false)
foreach ($sid in $allowed) {
$rule = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
$null = $acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $path -AclObject $acl
}
$item = Get-Item -LiteralPath $path
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Not a real directory: $path" }
$acl = Get-Acl -LiteralPath $path
if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $identity.Value) { throw "Directory must belong to current account: $path" }
foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) {
if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin $allowed) { throw "Directory permits another account: $path" }
}
}
if (-not $CheckOnly) {
$templates = Join-Path $PSScriptRoot '../config/windows-packaging'
foreach ($name in @('test', 'production')) {
$destination = Join-Path $rootPath "packaging/presets/$name.env.example"
if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates "$name.env.example") -Destination $destination }
}
$destination = Join-Path $rootPath 'packaging/secrets/windows-signing.env.example'
if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates 'windows-signing.env.example') -Destination $destination }
}
Write-Output "Packaging directories verified. Configuration root: $(Join-Path $rootPath 'packaging')"
Write-Output 'Existing configuration, caches, runner registration and signing failure records were not overwritten.'
@@ -0,0 +1,38 @@
# Start the existing registered runner with explicit machine configuration and a scrubbed environment.
[CmdletBinding()]
param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [switch]$CheckOnly)
$ErrorActionPreference='Stop'
$pwshRoot=Join-Path $RunnerRoot 'tools/pwsh-7.6.6'
$runnerPath = $pwshRoot + ';' + [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User')
$keep=@('SystemRoot','windir','SystemDrive','ComSpec','TEMP','TMP','USERPROFILE','HOMEDRIVE','HOMEPATH','LOCALAPPDATA','APPDATA','ProgramData','ProgramFiles','ProgramFiles(x86)','CommonProgramFiles','CommonProgramFiles(x86)','CommonProgramW6432','ProgramW6432','PROCESSOR_ARCHITECTURE','NUMBER_OF_PROCESSORS','OS','PATHEXT','USERNAME','USERDOMAIN','COMPUTERNAME','PUBLIC','ALLUSERSPROFILE')
foreach($entry in @(Get-ChildItem Env:)) {
if($entry.Name -notin $keep) { [Environment]::SetEnvironmentVariable($entry.Name,$null,'Process') }
}
$env:PATH=$runnerPath
if (-not [IO.Path]::IsPathRooted($ConfigRoot) -or -not (Test-Path -LiteralPath $ConfigRoot -PathType Container)) { throw 'ConfigRoot must identify an existing absolute directory' }
if ($ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'ConfigRoot requires an absolute local drive directory' }
$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot([IO.Path]::GetFullPath($ConfigRoot)))
if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a fixed local drive' }
$env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot)
$env:DSH_TELEMETRY_DISABLED='1'
$env:POWERSHELL_TELEMETRY_OPTOUT='1'
foreach($tool in @('pwsh','node','git','tar','python')) {
$resolved=Get-Command $tool -CommandType Application -ErrorAction Stop | Select-Object -First 1
Write-Output ('Tool: '+$tool+' -> '+$resolved.Source)
}
$env:PYTHON=(Get-Command python -CommandType Application | Select-Object -First 1).Source
& pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
if($LASTEXITCODE -ne 0){throw 'PowerShell probe failed'}
& node --version
if($LASTEXITCODE -ne 0){throw 'Node probe failed'}
& python --version
if($LASTEXITCODE -ne 0){throw 'Python probe failed'}
$vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe'
$visualStudio=& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if($LASTEXITCODE -ne 0 -or -not $visualStudio){throw 'Visual C++ tools unavailable'}
Write-Output 'Visual C++ tools located.'
if($CheckOnly){Write-Output 'Runner environment check passed.'; exit 0}
if(Get-Process -Name Runner.Listener -ErrorAction SilentlyContinue){throw 'A runner listener is already running; inspect before starting another.'}
Set-Location $RunnerRoot
& .\run.cmd
exit $LASTEXITCODE
+36 -1
View File
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
@@ -25,6 +25,41 @@ it('signs Mach-O files in their final locations and verifies each signature', as
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity, undefined)
expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity)
})
// File symlinks in these macOS fixtures require privileges unavailable to ordinary Windows processes.
it.skipIf(process.platform === 'win32')('signs a shared Python interpreter once and retains both executable aliases', async () => {
const path = root()
const bin = join(path, 'dependencies/python/bin')
mkdirSync(bin, { recursive: true })
writeFileSync(join(path, 'runtime.json'), JSON.stringify({ desktopVersion: '1', platform: 'darwin', arch: 'arm64', python: '3.12.14', pythonPackages: {} }))
writeFileSync(join(bin, 'python3.12'), Buffer.from('cffaedfe00000000', 'hex'))
for (const name of ['python', 'python3']) symlinkSync('python3.12', join(bin, name))
await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64', undefined, 'primary')).resolves.toBe(1)
expect(signMacOSRuntimeCode).toHaveBeenCalledExactlyOnceWith(join(bin, 'python3.12'), expect.any(String), identity, undefined)
for (const name of ['python', 'python3']) expect(readlinkSync(join(bin, name))).toBe('python3.12')
await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).rejects.toThrow('unsupported filesystem entry')
})
// The signer must inspect real file symlinks, including dangling and cyclic ones.
it.skipIf(process.platform === 'win32').each(['unknown', 'absolute', 'escaping', 'dangling', 'chained', 'directory'])('rejects a %s runtime link before signing any file', async (kind) => {
const path = root()
const bin = join(path, 'dependencies/python/bin')
mkdirSync(bin, { recursive: true })
writeFileSync(join(path, 'runtime.json'), JSON.stringify({ desktopVersion: '1', platform: 'darwin', arch: 'arm64', python: '3.12.14', pythonPackages: {} }))
const canonical = join(bin, 'python3.12')
if (kind === 'directory') mkdirSync(canonical)
else if (kind !== 'dangling') writeFileSync(canonical, Buffer.from('cffaedfe00000000', 'hex'))
if (kind === 'chained') {
rmSync(canonical)
symlinkSync('python3', canonical)
}
const target = kind === 'absolute' ? canonical : kind === 'escaping' ? '../outside' : 'python3.12'
symlinkSync(target, join(bin, kind === 'unknown' ? 'unlisted' : 'python3'))
const result = signMacOSRuntime(path, 'com.example.app', identity, 'arm64', undefined, 'primary')
if (kind === 'dangling') await expect(result).rejects.toMatchObject({ code: 'ENOENT', path: canonical })
else await expect(result).rejects.toThrow(kind === 'unknown' ? 'unsupported filesystem entry' : 'invalid Python alias')
expect(signMacOSRuntimeCode).not.toHaveBeenCalled()
})
it('awaits other signers before rejecting and permitting output cleanup', async () => {
const path = root()
for (const name of ['a.node', 'b.node']) writeFileSync(join(path, name), Buffer.from('cffaedfe00000000', 'hex'))
+14 -48
View File
@@ -1,56 +1,22 @@
import { parseEnv } from 'node:util'
import { describe, expect, it } from 'vitest'
import { windowsCiSettings } from '../scripts/prepare-windows-ci.mjs'
import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
import { windowsCiVersion } from '../scripts/prepare-windows-ci.mjs'
const inputs = {
PACKAGE_BUILD_VERSION: 'auto',
PACKAGE_DEPLOYMENT: 'test',
PACKAGE_POLICY_ORIGIN: 'https://policy.example.com',
PACKAGE_LOGIN_ORIGINS: 'https://login.example.com, https://sso.example.com',
GITHUB_RUN_NUMBER: '42',
GITHUB_RUN_ATTEMPT: '2',
}
const productVersion = '0.2.1-alpha.1'
const now = new Date('2026-10-08T23:59:59Z')
const inputs = { PACKAGE_BUILD_VERSION: 'auto', GITHUB_RUN_NUMBER: '42', GITHUB_RUN_ATTEMPT: '2' }
const now = new Date('2026-10-09T01:02:03Z')
describe('manual Windows package inputs', () => {
it('numbers fresh runners and reruns without storage credentials', () => {
const result = windowsCiSettings(inputs, productVersion, now)
expect(result.version).toBe('0.2.1-alpha.1.20261008.42.2')
const settings = parseEnv(result.settings)
expect(() => { validateDesktopPackageEnvironment(settings, { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow()
expect(Object.keys(settings).sort()).toEqual([
'DSH_DESKTOP_APP_ID', 'DSH_DESKTOP_AUTO_UPDATE_ENV',
'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN', 'DSH_DESKTOP_MANDATORY_UPDATE_CONFIG',
].sort())
describe('manual Windows CI version', () => {
it('uses the selected product prefix and UTC run identity', () => {
expect(windowsCiVersion(inputs, '0.2.1-alpha.1', now)).toBe('0.2.1-alpha.1.20261009.42.2')
expect(windowsCiVersion(inputs, '0.2.1', now)).toBe('0.2.1-test.20261009.42.2')
})
it('accepts an explicit version without a GitHub run identity', () => {
const result = windowsCiSettings({ ...inputs, PACKAGE_BUILD_VERSION: '0.2.1-alpha.1.20261008.7',
GITHUB_RUN_NUMBER: undefined, GITHUB_RUN_ATTEMPT: undefined }, productVersion, now)
expect(result.version).toBe('0.2.1-alpha.1.20261008.7')
it.each(['0.2.1-alpha.1', '0.2.1-alpha.1.20261009.7'])('accepts explicit version %s without run identity', (version) => {
expect(windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)).toBe(version)
})
it('uses the stable product test prefix and anonymous production policy', () => {
const result = windowsCiSettings({ ...inputs, PACKAGE_DEPLOYMENT: 'production', PACKAGE_LOGIN_ORIGINS: '' }, '0.2.1', now)
expect(result.version).toBe('0.2.1-test.20261008.42.2')
expect(() => { validateDesktopPackageEnvironment(parseEnv(result.settings), { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow()
it.each(['', 'bad', '0.3.0'])('reports checkout-specific guidance for %s', (version) => {
expect(() => windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now))
.toThrow(/auto, 0\.2\.1-alpha\.1, or e\.g\. 0\.2\.1-alpha\.1\.20261009\.1/u)
})
it.each([
{ PACKAGE_BUILD_VERSION: '' },
{ PACKAGE_BUILD_VERSION: '0.3.0' },
{ PACKAGE_DEPLOYMENT: 'staging' },
{ PACKAGE_POLICY_ORIGIN: '' },
{ PACKAGE_POLICY_ORIGIN: 'http://policy.example.com' },
{ PACKAGE_POLICY_ORIGIN: 'https://policy.example.com/path' },
{ PACKAGE_LOGIN_ORIGINS: '' },
{ PACKAGE_LOGIN_ORIGINS: 'https://user:[email protected]' },
{ PACKAGE_DEPLOYMENT: 'production' },
{ GITHUB_RUN_NUMBER: '0' },
{ GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' },
])('rejects invalid manual settings %j', (override) => {
expect(() => windowsCiSettings({ ...inputs, ...override }, productVersion, now)).toThrow()
it.each([{ GITHUB_RUN_NUMBER: '0' }, { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' }])('rejects invalid run identity %j', (override) => {
expect(() => windowsCiVersion({ ...inputs, ...override }, '0.2.1', now)).toThrow(/positive integer/u)
})
})
+1 -1
View File
@@ -30,7 +30,7 @@ it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace-
await mkdir(cwd)
vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
const runtime = await mountRuntime(ctx, { launch: { kind: 'node-script', executable: electron } }, { mode, workspaceRoot: cwd })
const { runtime } = await mountRuntime(ctx, { launch: { kind: 'node-script', executable: electron } }, { mode, workspaceRoot: cwd })
const result = await runtime.run(runtime.resolve({
// Bound startup failure before the outer test deadline, leaving time for managed cleanup.
timeoutMs: 30_000,
@@ -27,6 +27,8 @@ it('omits development artifacts while preserving executable modules, assets and
'node-pty/prebuilds/win32-x64/conpty.pdb',
'@koromix/koffi-win32-x64/win32_x64/koffi.lib',
'@mixmark-io/domino/test/entities.html',
'@mixmark-io/domino/.yarn/plugins/@yarnpkg/plugin-version.cjs',
'@mixmark-io/domino/.yarn/versions/release.yml',
'@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data',
'@deepseek-ai/libreoffice-kit-darwin-arm64/bin/libreoffice-kit',
'.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache',
@@ -45,6 +47,8 @@ it('omits development artifacts while preserving executable modules, assets and
'node-pty/third_party/conpty/win10-x64/OpenConsole.exe',
'@koromix/koffi-win32-x64/win32_x64/koffi.node',
'@mixmark-io/domino/lib/HTMLParser.js', '@mixmark-io/domino/lib/EntityParser.js',
'@mixmark-io/domino/LICENSE', '@mixmark-io/domino/package.json',
'example/.yarn/runtime.cjs', '@mixmark-io/domino/.yarn-data.json',
'@img/sharp-win32-x64/lib/libvips-42.dll',
'@deepseek-ai/libreoffice-kit-win32-x64/bin/libreoffice-kit.exe',
'@deepseek-ai/libreoffice-kit-win32-x64/sources/core-source.json',
@@ -73,6 +77,8 @@ it('omits development artifacts while preserving executable modules, assets and
it('applies package-specific rules inside scoped and nested dependency containers', () => {
expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows, 'win32-x64')).toBeDefined()
expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/.yarn/plugin.cjs', windows, 'win32-x64')).toBeDefined()
expect(desktopRuntimeFileExclusion('@mixmark-io/domino/.yarn', windows, 'win32-x64')).toBeDefined()
expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows, 'win32-x64')).toBeDefined()
expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows, 'win32-x64')).toBeUndefined()
expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows, 'win32-x64')).toBeUndefined()
@@ -0,0 +1,93 @@
import { spawnSync } from 'node:child_process'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
async function withPreset(run: (root: string) => Promise<void>) {
const root = await mkdtemp(join(tmpdir(), 'dsh-windows-preset-'))
try {
await mkdir(join(root, 'presets'))
await mkdir(join(root, 'secrets'))
await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_APP_ID=com.example.desktop\n')
await run(root)
} finally {
await rm(root, { recursive: true, force: true })
}
}
it('loads unsigned public settings without requiring the signing secret file or personal dotenv', async () => {
await withPreset(async (root) => {
const environment = loadDesktopPackageEnvironment('win32', {
DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'ambient-secret',
}, join(root, 'missing-checkout'), { unsigned: true })
expect(environment.DSH_DESKTOP_APP_ID).toBe('com.example.desktop')
expect(environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined()
const prepared = loadDesktopPackageEnvironment('win32', {
DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test',
}, root, { prepareOnly: true })
expect(prepared.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined()
expect(prepared.DSH_PACKAGING_PRESET).toBe('test')
expect(() => loadDesktopPackageEnvironment('win32', { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }))
.toThrow(/cannot read/u)
})
})
it('resolves signing file paths relative to the secret file and rejects public overrides there', async () => {
await withPreset(async (root) => {
const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }
const file = join(root, 'secrets/windows-signing.env')
await writeFile(file, 'DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/ev.cer\nDSH_DESKTOP_WINDOWS_TOKEN_PIN="private-value"\n')
const result = loadDesktopPackageEnvironment('win32', env)
expect(result.DSH_DESKTOP_WINDOWS_CER_FILE).toBe(join(root, 'certificates/ev.cer'))
expect(result.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBe('private-value')
await writeFile(file, 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n')
expect(() => loadDesktopPackageEnvironment('win32', env)).toThrow(/unsupported setting/u)
})
})
it('preserves the selected preset when Windows environment enumeration uses lowercase keys', async () => {
await withPreset(async (root) => {
const values: NodeJS.ProcessEnv = { dsh_packaging_config_dir: root, dsh_packaging_preset: 'test' }
const environment = new Proxy(values, {
get: (target, name) => typeof name === 'string' ? target[name.toLowerCase()] : undefined,
})
const selected = loadDesktopPackageEnvironment('win32', environment, root, { unsigned: true })
expect(selected.DSH_PACKAGING_PRESET).toBe('test')
})
})
it('rejects missing presets, traversal, deployment mismatches and secrets in public configuration', async () => {
await withPreset(async (root) => {
const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }
const load = (override = {}) => loadDesktopPackageEnvironment('win32', { ...env, ...override }, root, { unsigned: true })
expect(() => load({ DSH_PACKAGING_CONFIG_DIR: 'relative' })).toThrow(/absolute/u)
expect(() => load({ DSH_PACKAGING_CONFIG_DIR: undefined })).toThrow(/absolute/u)
expect(() => loadDesktopPackageEnvironment('darwin', env)).toThrow(/Windows only/u)
expect(() => load({ DSH_PACKAGING_PRESET: '../secrets' })).toThrow(/preset must/u)
expect(() => load({ DSH_PACKAGING_PRESET: 'production' })).toThrow(/cannot read/u)
await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n')
expect(() => load()).toThrow(/must match/u)
await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_WINDOWS_TOKEN_PIN=private-value\n')
expect(() => load()).toThrow(/unsupported setting DSH_DESKTOP_WINDOWS_TOKEN_PIN/u)
expect(() => load()).not.toThrow(/private-value/u)
})
})
it('cache maintenance reaches cache path validation without a signing file', async () => {
await withPreset(async (root) => {
await writeFile(join(root, 'presets/test.env'),
'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=relative-cache\n')
const result = spawnSync(process.execPath,
[join(import.meta.dirname, '../scripts/manage-windows-signature-cache.mjs'), '--usage'], {
env: { ...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' },
encoding: 'utf8', timeout: 10_000, windowsHide: true,
})
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status).toBe(1)
expect(result.stderr).toContain('absolute local')
expect(result.stderr).not.toContain('cannot read')
})
})
+33
View File
@@ -0,0 +1,33 @@
/** WOFF2-only KaTeX font sources for the Web and Desktop browser bundle. */
import { transform } from 'lightningcss'
import type { Plugin } from 'vite'
/**
* Select WOFF2 before Vite collects KaTeX font assets, preserving every face and layout rule.
* A face without WOFF2 rejects the build. Other packages' stylesheets are unchanged.
* @returns Vite stylesheet transform for the installed KaTeX distribution.
*/
export function katexWoff2Fonts(): Plugin {
return {
name: 'dsh-katex-woff2-fonts',
enforce: 'pre',
transform(code, id) {
if (!/\/node_modules\/katex\/dist\/katex(?:\.min)?\.css(?:\?|$)/u.test(id.replaceAll('\\', '/'))) return
const result = transform({
filename: id, code: Buffer.from(code), sourceMap: true,
visitor: { Rule: { 'font-face'(rule) {
const descriptors = rule.value.properties.filter(property => property.type === 'source')
if (descriptors.length === 0) throw new Error('KaTeX font face requires a WOFF2 source')
for (const property of descriptors) {
const sources = property.value.filter(source => source.type === 'url' && source.value.format?.type === 'woff2')
if (sources.length === 0) throw new Error('KaTeX font face requires a WOFF2 source')
property.value = sources
}
return rule
} } },
})
return { code: result.code.toString(), map: result.map?.toString() }
},
}
}
Loaded 100 of 1458 files, more files were not shown because too many files have changed in this diff. Show more