From bdff8573b686773fc5d82ab71eb047e8cb7a48c8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 12:44:00 +0800 Subject: [PATCH 01/56] ci: run coverage on in-house vm-backup pool Coverage does not gate merges, so move it off the metered dsh-enterprise-ubuntu-24-04-32core-test pool onto the in-house self-hosted pool (vm-backup label, 64-core). Also switch the pnpm store cache path to ~ so it resolves under both /home/runner (hosted) and self-hosted home directories. Verified on the self-hosted pool: the full coverage job (including prepare-ci-bubblewrap and the exhaustive suite) completed green in ~5 min. --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eceefa114..a2e70cab6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,9 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-24-04-32core-test + # Coverage does not gate merges, so it runs on the in-house pool + # (self-hosted, 64-core) instead of the metered enterprise pool. + runs-on: [self-hosted, linux, x64, vm-backup] name: node 24 / coverage env: DSH_COVERAGE_MAX_WORKERS: '24' @@ -89,7 +91,8 @@ jobs: - uses: actions/cache/restore@v4 with: - path: /home/runner/.local/share/pnpm/store/v11 + # ~ resolves on both hosted (/home/runner) and self-hosted homes + path: ~/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- From 81890d7a994ab791c7db8bc93667caf21fc38f45 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 16:37:59 +0800 Subject: [PATCH 02/56] =?UTF-8?q?ci:=20address=20review=20=E2=80=94=20same?= =?UTF-8?q?-repo=20guard,=20keep=20cache=20path=20identical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restrict node-24-coverage to same-repo PRs so fork-originated code can never reach the self-hosted runner (defense in depth; the repo is private with forking disabled today). - Revert the pnpm cache path to the literal /home/runner/... save-side path: actions/cache hashes the path into the cache version, so the ~ variant could never match the cache saved by the master lane. On self-hosted the persistent local pnpm store covers warm installs. - Drop the incorrect 'does not gate merges' claim: node-24-coverage is needed by all-checks-passed. Pool capacity notes moved into comments. --- .github/workflows/ci.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2e70cab6f..dd60593a85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,14 @@ jobs: compression-level: 0 node-24-coverage: - if: github.event_name == 'pull_request' - # Coverage does not gate merges, so it runs on the in-house pool - # (self-hosted, 64-core) instead of the metered enterprise pool. + # Same-repo PRs only: this lane runs on an in-house self-hosted runner, + # so fork-originated code must never land here. The repo is currently + # private with forking disabled; this guard keeps that invariant explicit + # if either setting ever changes. + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + # Runs on the in-house pool (self-hosted, 64-core) instead of the metered + # enterprise pool. The pool holds 4 always-on instances plus 4 registered + # spares; the runner service is systemd-managed and self-healing. runs-on: [self-hosted, linux, x64, vm-backup] name: node 24 / coverage env: @@ -91,8 +96,12 @@ jobs: - uses: actions/cache/restore@v4 with: - # ~ resolves on both hosted (/home/runner) and self-hosted homes - path: ~/.local/share/pnpm/store/v11 + # Path must stay byte-identical to the save-side path in the master + # lane: actions/cache hashes the literal path into the cache version, + # so any variation (e.g. ~) would never match the saved cache. On + # self-hosted this restore simply misses and the persistent local + # pnpm store covers warm installs instead. + path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- From 5818fd62242f8799484fbf166c11f1fc8434bf48 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:00:56 +0800 Subject: [PATCH 03/56] =?UTF-8?q?ci:=20address=20second=20review=20round?= =?UTF-8?q?=20=E2=80=94=20dependabot=20lane,=20drop=20dead=20restore,=20up?= =?UTF-8?q?date=20topology=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route untrusted PRs (forks + Dependabot, same author test as e2e.yml) back to the hosted enterprise pool via a runs-on expression: Dependabot PRs are same-repo, so the previous head.repo guard admitted dependency-supplied code onto the persistent self-hosted VM. A single job with pool selection keeps all-checks-passed free of skips. - Drop the pnpm-store cache restore from this lane: on self-hosted the hosted-path cache actually HIT (Linux key) and spent ~52 s pulling 181 MB into a path pnpm never reads; the persistent local store already serves warm installs in seconds. - Update the larger-hosted-runners Agent Note (en/zh + i18n pairing record) so the decision record describes the shipped topology: coverage on the in-house vm-backup pool for trusted PRs, hosted Ubuntu 24.04 32-core retained for untrusted PRs. --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 2 +- ...evidence-based-larger-hosted-runners.zh.md | 2 +- .github/workflows/ci.yml | 39 +++++++++---------- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 9d87cb9ad3..360395102e 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: aaeab4ed9ae9687598f9f1d4a862120405697672 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 72b69c85908990a9f35b60f4c0a2ce213f9c8134 +2026-07-22-evidence-based-larger-hosted-runners.md: c3e6344ae61669da4810090e558589875ca7536e +2026-07-22-evidence-based-larger-hosted-runners.zh.md: e5b322673b7a1eb004eb15b3784d21f500e83719 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index aaeab4ed9a..c3e6344ae6 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -12,7 +12,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep ## Decision -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name three 32-core pools directly: Ubuntu 24.04 for exhaustive coverage, Ubuntu latest for the remaining primary Node 24 inventory, and Windows 2025 for blocking Windows contracts. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 72b69c8590..e5b322673b 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 3 个 32 核运行器池:Ubuntu 24.04 用于完整覆盖率,Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd60593a85..dc45bc9a7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,15 +76,19 @@ jobs: compression-level: 0 node-24-coverage: - # Same-repo PRs only: this lane runs on an in-house self-hosted runner, - # so fork-originated code must never land here. The repo is currently - # private with forking disabled; this guard keeps that invariant explicit - # if either setting ever changes. - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - # Runs on the in-house pool (self-hosted, 64-core) instead of the metered - # enterprise pool. The pool holds 4 always-on instances plus 4 registered - # spares; the runner service is systemd-managed and self-healing. - runs-on: [self-hosted, linux, x64, vm-backup] + if: github.event_name == 'pull_request' + # Trusted same-repo PRs run on the in-house pool (self-hosted, 64-core; + # 4 always-on systemd-managed instances plus 4 registered spares) instead + # of the metered enterprise pool. Untrusted PRs — forks and Dependabot + # (same-repo but dependency-supplied code; same author test as e2e.yml) — + # stay on the hosted enterprise pool so no untrusted code reaches the + # persistent self-hosted VM. Selecting the pool via runs-on keeps this a + # single job, so the all-checks-passed aggregate never sees a skip. + runs-on: >- + ${{ (github.event.pull_request.head.repo.full_name != github.repository + || github.event.pull_request.user.login == 'dependabot[bot]') + && 'dsh-enterprise-ubuntu-24-04-32core-test' + || fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') }} name: node 24 / coverage env: DSH_COVERAGE_MAX_WORKERS: '24' @@ -94,17 +98,12 @@ jobs: with: persist-credentials: false - - uses: actions/cache/restore@v4 - with: - # Path must stay byte-identical to the save-side path in the master - # lane: actions/cache hashes the literal path into the cache version, - # so any variation (e.g. ~) would never match the saved cache. On - # self-hosted this restore simply misses and the persistent local - # pnpm store covers warm installs instead. - path: /home/runner/.local/share/pnpm/store/v11 - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + # No pnpm-store cache restore in this lane: on the self-hosted pool + # pnpm's persistent store lives outside /home/runner, so restoring the + # hosted cache here downloads ~180 MB into a path pnpm never reads + # (measured: 52 s restore, then a 2.8 s install straight from the + # persistent store). The rare hosted (untrusted-PR) run just does a + # cold install. - uses: actions/setup-node@v6 with: From e532c9ccc245a2360df74bb6d4795ea1f3c13162 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:12:20 +0800 Subject: [PATCH 04/56] ci: restore pnpm cache on the hosted leg only Keep the cache restore for the ephemeral hosted (untrusted-PR) leg where it is a genuine speedup, gated by the same expression as the runs-on pool selector; the self-hosted leg skips it and installs from the persistent local store. --- .github/workflows/ci.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc45bc9a7a..d0d51fde9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,12 +98,21 @@ jobs: with: persist-credentials: false - # No pnpm-store cache restore in this lane: on the self-hosted pool - # pnpm's persistent store lives outside /home/runner, so restoring the - # hosted cache here downloads ~180 MB into a path pnpm never reads - # (measured: 52 s restore, then a 2.8 s install straight from the - # persistent store). The rare hosted (untrusted-PR) run just does a - # cold install. + # Restore the pnpm-store cache only on the hosted (untrusted-PR) leg, + # where the VM is ephemeral and the same-region download is fast. On + # the self-hosted leg pnpm's persistent store lives outside + # /home/runner, so this restore would spend ~52 s pulling ~180 MB into + # a path pnpm never reads (measured; install then took 2.8 s straight + # from the persistent store). Condition mirrors the runs-on selector. + - uses: actions/cache/restore@v4 + if: >- + github.event.pull_request.head.repo.full_name != github.repository + || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: /home/runner/.local/share/pnpm/store/v11 + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - uses: actions/setup-node@v6 with: From 8d53d44b6055ce37aecdd22be1eb9d1429a96cae Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:49:58 +0800 Subject: [PATCH 05/56] docs(ci): reconcile every present-tense topology description with the coverage lane move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep all remaining sources that still described coverage as an enterprise 32-core job: the ci.yml jobs preamble, the three-job paragraph of the larger-hosted-runners note, and the required-pool sentence of the portable-recovery note — English and Chinese sides of both notes, with their i18n pairing records re-recorded. --- ...026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- .../2026-07-23-portable-required-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-07-23-portable-required-pull-request-ci.md | 2 +- .../2026-07-23-portable-required-pull-request-ci.zh.md | 2 +- .github/workflows/ci.yml | 6 ++++-- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 360395102e..4d781caa54 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: c3e6344ae61669da4810090e558589875ca7536e -2026-07-22-evidence-based-larger-hosted-runners.zh.md: e5b322673b7a1eb004eb15b3784d21f500e83719 +2026-07-22-evidence-based-larger-hosted-runners.md: 88b9e6d83777172d8afb6a391512e5f293b81171 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: b1105f00cd08b1af633d258ea4ff28a835ce6074 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index c3e6344ae6..88b9e6d837 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -18,7 +18,7 @@ The required primary path depends on those enterprise pools. Standard GitHub-hos The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. -Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +Linux primary work uses three independent jobs: static gates and the consumer tail on hosted 32-core pools, and coverage on the in-house self-hosted 64-core pool for trusted PRs (hosted 32-core for untrusted ones). Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index e5b322673b..b1105f00cd 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -18,7 +18,7 @@ Status: implemented 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 -Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +Linux 主流程使用 3 个相互独立的作业:静态门禁与消费方尾部作业运行在托管 32 核池上,覆盖率对可信拉取请求运行在公司自有的自托管 64 核池上(不可信请求仍用托管 32 核池)。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index f8b54b0ec5..ed97fe08a7 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-portable-required-pull-request-ci.md: 9cf8d97016300c5258c075879176aa6abd64e59e -2026-07-23-portable-required-pull-request-ci.zh.md: c6839a133d0c3fe7a699362f6168e17d827a5b61 +2026-07-23-portable-required-pull-request-ci.md: 29b2cfa3f431a4a8be4aaa685b16cffdb4bf2593 +2026-07-23-portable-required-pull-request-ci.zh.md: 8b6d067637ce83c09529977f463f16dfa4af5a8b diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 9cf8d97016..29b2cfa3f4 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,7 +12,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools, except exhaustive coverage, which runs on the in-house self-hosted 64-core pool for trusted same-repo pull requests (hosted 32-core for forks and Dependabot). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when an enterprise label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index c6839a133d..8b6d067637 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业;唯一例外是完整覆盖率——可信的同仓库拉取请求在公司自有的自托管 64 核池上运行(fork 与 Dependabot 仍用托管 32 核池)。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。企业级运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d51fde9b..a21086754b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,10 @@ env: jobs: - # Three enterprise jobs isolate coverage, static analysis, and the - # build-backed consumer tail. The static job publishes its exact build so + # Three independent Linux jobs isolate coverage, static analysis, and the + # build-backed consumer tail: static and consumers on hosted enterprise + # 32-core pools; coverage on the in-house self-hosted pool for trusted PRs + # (hosted for forks/Dependabot). The static job publishes its exact build so # consumers do not repeat the longest part of their critical path. node-24: if: github.event_name == 'pull_request' From 1a5d892ec53beb5f1b7212decfc2a10bd9ea2741 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 25 Jul 2026 00:45:31 +0800 Subject: [PATCH 06/56] ci: halve coverage workers on the shared self-hosted leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted 32-core runner is exclusive to one job, but the vm-backup pool shares one 64-core VM across four runner instances; concurrent PRs could stack 4×24 = 96 Vitest workers and re-trigger the documented aggregate-contention failures in the timing-sensitive process suites. Bound the self-hosted leg at 12 workers per job (48 host-wide fully loaded) and keep 24 on the hosted leg, selected by the same expression as the pool. --- .github/workflows/ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a21086754b..dc5ad98ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,16 @@ jobs: || fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') }} name: node 24 / coverage env: - DSH_COVERAGE_MAX_WORKERS: '24' + # Worker bound is per-leg: the hosted 32-core runner is exclusive to + # one job, but the self-hosted pool shares one 64-core VM across four + # runner instances, so concurrent PRs would otherwise stack up to + # 4×24 = 96 workers and re-trigger the aggregate-contention failures + # documented for the timing-sensitive process suites. 12 per job caps + # the shared host at 48 workers even fully loaded. + DSH_COVERAGE_MAX_WORKERS: >- + ${{ (github.event.pull_request.head.repo.full_name != github.repository + || github.event.pull_request.user.login == 'dependabot[bot]') + && '24' || '12' }} DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 From f09539581d33a5110c97d81cfe2778c74337690e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 25 Jul 2026 00:54:35 +0800 Subject: [PATCH 07/56] docs(ci): record disabled forking as an explicit precondition of the self-hosted lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool selector is defense-in-depth only — pull_request executes the PR's own workflow definition, so YAML cannot enforce runner trust. Make the actual enforcement boundary explicit in the decision record: org-side disabled forking (the public release is an isolated read-only mirror under a separate org), with migration to a repo-restricted org-level runner group with base-branch workflow pinning as a hard gate before forking could ever be enabled. --- .../2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 4d781caa54..ea3a57e072 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: 88b9e6d83777172d8afb6a391512e5f293b81171 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: b1105f00cd08b1af633d258ea4ff28a835ce6074 +2026-07-22-evidence-based-larger-hosted-runners.md: 497c6f297d79245fb40cd30457e4b1d1e36db651 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: bcb0c6e9f11081b2cff696a9b6b425a40ee4aeb4 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 88b9e6d837..497c6f297d 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -12,7 +12,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep ## Decision -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. **Precondition: repository forking stays disabled.** The workflow's pool selector is defense-in-depth only — `pull_request` executes the PR's own workflow definition, so YAML cannot enforce runner trust against a fork that edits it. Disabled forking (org-side, not PR-editable) is the enforcement boundary; the planned public release is an isolated read-only mirror under a separate org, preserving this. Before forking is ever enabled, the runners must first move into an org-level runner group restricted to this repository with base-branch workflow pinning — that migration is the gate, not a follow-up. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index b1105f00cd..bcb0c6e9f1 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。**前置条件:仓库必须保持禁用 fork。**工作流中的运行器池选择表达式仅是纵深防御——`pull_request` 执行的是拉取请求自带的工作流定义,因此 YAML 无法对能修改它的 fork 实施运行器信任约束。真正的强制边界是组织侧(拉取请求无法修改)的 fork 禁用设置;规划中的开源发布采用独立组织下的只读镜像仓库,正是为了保持这一边界。将来若要启用 fork,必须先把运行器迁入组织级 runner group(限定本仓库并绑定基线分支工作流)——该迁移是启用 fork 的先决门槛,而非事后跟进项。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 From 310a387b144526354bec79ab8f913cd419fcf570 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 00:08:26 +0800 Subject: [PATCH 08/56] =?UTF-8?q?ci:=20pivot=20=E2=80=94=20keep=20coverage?= =?UTF-8?q?=20hosted,=20add=20self-hosted=20serial=20standby=20lane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direction change after review discussion. Moving a REQUIRED check onto a single in-house VM traded merge-path availability for modest savings and accumulated trust/contention caveats (six review rounds' worth). Revert every coverage-lane change: coverage stays on the enterprise Ubuntu 24.04 32-core pool exactly as on master. Instead, add serial-linux-selfhosted: on every master push the in-house pool (vm-backup) runs the complete unsharded primary aggregate as a hot-standby drill. It blocks nothing, yet continuously proves the environment end to end, so any hosted-pool outage can be answered with a one-line runs-on retarget onto continuously verified capacity. Push-triggered lanes execute the base branch's own workflow definition, so no PR-editable path selects these runners — the entire fork-trust discussion is structurally moot for this lane. Topology notes (en/zh + pairing records) describe the standby lane and the switch play. --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 6 +- ...evidence-based-larger-hosted-runners.zh.md | 6 +- ...ortable-required-pull-request-ci.i18n.yaml | 4 +- ...07-23-portable-required-pull-request-ci.md | 2 +- ...23-portable-required-pull-request-ci.zh.md | 2 +- .github/workflows/ci.yml | 78 ++++++++++--------- 7 files changed, 57 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index ea3a57e072..1b14f5b689 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: 497c6f297d79245fb40cd30457e4b1d1e36db651 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: bcb0c6e9f11081b2cff696a9b6b425a40ee4aeb4 +2026-07-22-evidence-based-larger-hosted-runners.md: 6654f5eb3e21b48c6d33fd9d74ebd23cf3065d54 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 3fe5715b20d3b881f8fb439b61900bdb84e5a588 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 497c6f297d..6654f5eb3e 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -12,13 +12,13 @@ Larger runners make it possible to pay setup once and parallelize inside the rep ## Decision -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. **Precondition: repository forking stays disabled.** The workflow's pool selector is defense-in-depth only — `pull_request` executes the PR's own workflow definition, so YAML cannot enforce runner trust against a fork that edits it. Disabled forking (org-side, not PR-editable) is the enforcement boundary; the planned public release is an isolated read-only mirror under a separate org, preserving this. Before forking is ever enabled, the runners must first move into an org-level runner group restricted to this repository with base-branch workflow pinning — that migration is the gate, not a follow-up. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name three 32-core pools directly: Ubuntu 24.04 for exhaustive coverage, Ubuntu latest for the remaining primary Node 24 inventory, and Windows 2025 for blocking Windows contracts. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. -Linux primary work uses three independent jobs: static gates and the consumer tail on hosted 32-core pools, and coverage on the in-house self-hosted 64-core pool for trusted PRs (hosted 32-core for untrusted ones). Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. @@ -48,6 +48,8 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate, so if the enterprise pools degrade, a required lane can be retargeted with a one-line `runs-on` change onto an environment with continuously verified evidence. Because the lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. + ## Alternatives considered **Keep the three coarse primary Linux lanes.** The core, CPU, and production-site jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index bcb0c6e9f1..3fe5715b20 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。**前置条件:仓库必须保持禁用 fork。**工作流中的运行器池选择表达式仅是纵深防御——`pull_request` 执行的是拉取请求自带的工作流定义,因此 YAML 无法对能修改它的 fork 实施运行器信任约束。真正的强制边界是组织侧(拉取请求无法修改)的 fork 禁用设置;规划中的开源发布采用独立组织下的只读镜像仓库,正是为了保持这一边界。将来若要启用 fork,必须先把运行器迁入组织级 runner group(限定本仓库并绑定基线分支工作流)——该迁移是启用 fork 的先决门槛,而非事后跟进项。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 3 个 32 核运行器池:Ubuntu 24.04 用于完整覆盖率,Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 -Linux 主流程使用 3 个相互独立的作业:静态门禁与消费方尾部作业运行在托管 32 核池上,覆盖率对可信拉取请求运行在公司自有的自托管 64 核池上(不可信请求仍用托管 32 核池)。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 @@ -48,6 +48,8 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程,因此当企业池发生故障时,只需一行 `runs-on` 修改即可把必需通道切换到一个具有持续验证证据的环境上。该通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 + ## 曾考虑的替代方案 **保留 3 个粗粒度 Linux 主流程通道。** 核心、CPU 和生产网站作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index ed97fe08a7..f8b54b0ec5 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-portable-required-pull-request-ci.md: 29b2cfa3f431a4a8be4aaa685b16cffdb4bf2593 -2026-07-23-portable-required-pull-request-ci.zh.md: 8b6d067637ce83c09529977f463f16dfa4af5a8b +2026-07-23-portable-required-pull-request-ci.md: 9cf8d97016300c5258c075879176aa6abd64e59e +2026-07-23-portable-required-pull-request-ci.zh.md: c6839a133d0c3fe7a699362f6168e17d827a5b61 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 29b2cfa3f4..9cf8d97016 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,7 +12,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools, except exhaustive coverage, which runs on the in-house self-hosted 64-core pool for trusted same-repo pull requests (hosted 32-core for forks and Dependabot). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when an enterprise label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index 8b6d067637..c6839a133d 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业;唯一例外是完整覆盖率——可信的同仓库拉取请求在公司自有的自托管 64 核池上运行(fork 与 Dependabot 仍用托管 32 核池)。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。企业级运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc5ad98ec4..2666c93b8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,8 @@ env: jobs: - # Three independent Linux jobs isolate coverage, static analysis, and the - # build-backed consumer tail: static and consumers on hosted enterprise - # 32-core pools; coverage on the in-house self-hosted pool for trusted PRs - # (hosted for forks/Dependabot). The static job publishes its exact build so + # Three enterprise jobs isolate coverage, static analysis, and the + # build-backed consumer tail. The static job publishes its exact build so # consumers do not repeat the longest part of their critical path. node-24: if: github.event_name == 'pull_request' @@ -79,46 +77,17 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' - # Trusted same-repo PRs run on the in-house pool (self-hosted, 64-core; - # 4 always-on systemd-managed instances plus 4 registered spares) instead - # of the metered enterprise pool. Untrusted PRs — forks and Dependabot - # (same-repo but dependency-supplied code; same author test as e2e.yml) — - # stay on the hosted enterprise pool so no untrusted code reaches the - # persistent self-hosted VM. Selecting the pool via runs-on keeps this a - # single job, so the all-checks-passed aggregate never sees a skip. - runs-on: >- - ${{ (github.event.pull_request.head.repo.full_name != github.repository - || github.event.pull_request.user.login == 'dependabot[bot]') - && 'dsh-enterprise-ubuntu-24-04-32core-test' - || fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') }} + runs-on: dsh-enterprise-ubuntu-24-04-32core-test name: node 24 / coverage env: - # Worker bound is per-leg: the hosted 32-core runner is exclusive to - # one job, but the self-hosted pool shares one 64-core VM across four - # runner instances, so concurrent PRs would otherwise stack up to - # 4×24 = 96 workers and re-trigger the aggregate-contention failures - # documented for the timing-sensitive process suites. 12 per job caps - # the shared host at 48 workers even fully loaded. - DSH_COVERAGE_MAX_WORKERS: >- - ${{ (github.event.pull_request.head.repo.full_name != github.repository - || github.event.pull_request.user.login == 'dependabot[bot]') - && '24' || '12' }} + DSH_COVERAGE_MAX_WORKERS: '24' DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 with: persist-credentials: false - # Restore the pnpm-store cache only on the hosted (untrusted-PR) leg, - # where the VM is ephemeral and the same-region download is fast. On - # the self-hosted leg pnpm's persistent store lives outside - # /home/runner, so this restore would spend ~52 s pulling ~180 MB into - # a path pnpm never reads (measured; install then took 2.8 s straight - # from the persistent store). Condition mirrors the runs-on selector. - uses: actions/cache/restore@v4 - if: >- - github.event.pull_request.head.repo.full_name != github.repository - || github.event.pull_request.user.login == 'dependabot[bot]' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -396,6 +365,45 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + # Hot-standby drill for the in-house self-hosted pool: every master move + # re-runs the complete unsharded aggregate on the persistent 64-core VM, + # continuously proving that environment can take over a required lane if + # the hosted pools degrade (the switch is then a one-line runs-on change). + # Push-triggered, so it always executes the base branch's own workflow + # definition — no PR-editable path selects these runners. Non-blocking for + # pull requests; no cache steps because the VM's persistent pnpm store and + # tool caches make them redundant (and saving here would poison the hosted + # cache namespace with self-hosted paths). + serial-linux-selfhosted: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + name: serial / linux (self-hosted standby) + runs-on: [self-hosted, linux, x64, vm-backup] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Prepare bubblewrap (unrestrict userns) + run: bash scripts/prepare-ci-bubblewrap.sh + + - name: Run complete unsharded primary Node CI serially + env: + DSH_COVERAGE_MAX_WORKERS: '1' + DSH_E2E_MAX_WORKERS: '1' + DSH_ESLINT_CACHE: '1' + DSH_GATE_CONCURRENCY: '1' + DSH_PUBLINT_CONCURRENCY: '1' + DSH_SNAPSHOT_MAX_CONCURRENCY: '1' + run: pnpm run check:ci + serial-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / macos From 0fd6dc8924a087db5c3a8190a2f1783766660e8b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 00:34:53 +0800 Subject: [PATCH 09/56] ci: pre-wire admin-only failover from hosted pools to the in-house pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three required Linux jobs now resolve their pool through the DSH_CI_FAILOVER repository variable. Unset, everything runs exactly as today on the hosted enterprise pools. Setting it to 'selfhosted' (repo-admin-only, not PR-editable, no merge required — a merge would be deadlocked behind the failing checks themselves) retargets all three onto the vm-backup pool, halves the coverage worker bound and snapshot concurrency for the shared VM, and skips the hosted-path cache restores. Adds a bilingual failover runbook (switch, capacity via the four registered spare instances, switch-back, trust boundary) and links it from the topology note. The push-triggered standby lane remains the continuous proof that the failover target works. --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 2 +- ...evidence-based-larger-hosted-runners.zh.md | 2 +- .../process/ci-failover-runbook.i18n.yaml | 6 +++ .../process/ci-failover-runbook.md | 33 +++++++++++++++ .../process/ci-failover-runbook.zh.md | 33 +++++++++++++++ .github/workflows/ci.yml | 40 ++++++++++++++++--- 7 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/process/ci-failover-runbook.i18n.yaml create mode 100644 .agents/notes/implemented/process/ci-failover-runbook.md create mode 100644 .agents/notes/implemented/process/ci-failover-runbook.zh.md diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 1b14f5b689..cd3ae7a181 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: 6654f5eb3e21b48c6d33fd9d74ebd23cf3065d54 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 3fe5715b20d3b881f8fb439b61900bdb84e5a588 +2026-07-22-evidence-based-larger-hosted-runners.md: dd07280092565257f4b5324f997d5efd4c9c51cc +2026-07-22-evidence-based-larger-hosted-runners.zh.md: a9c034b643da0cb9148d08c0300f3e142eec31e6 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 6654f5eb3e..dd07280092 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -48,7 +48,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate, so if the enterprise pools degrade, a required lane can be retargeted with a one-line `runs-on` change onto an environment with continuously verified evidence. Because the lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 3fe5715b20..a9c034b643 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -48,7 +48,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程,因此当企业池发生故障时,只需一行 `runs-on` 修改即可把必需通道切换到一个具有持续验证证据的环境上。该通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](ci-failover-runbook.zh.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/ci-failover-runbook.i18n.yaml new file mode 100644 index 0000000000..294ed38ddf --- /dev/null +++ b/.agents/notes/implemented/process/ci-failover-runbook.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +ci-failover-runbook.md: d22c93fbedd3216e71bc24101dfa06dc606521c2 +ci-failover-runbook.zh.md: d7d26287191165cd3cb2666de4b1c7d6217ba71d diff --git a/.agents/notes/implemented/process/ci-failover-runbook.md b/.agents/notes/implemented/process/ci-failover-runbook.md new file mode 100644 index 0000000000..d22c93fbed --- /dev/null +++ b/.agents/notes/implemented/process/ci-failover-runbook.md @@ -0,0 +1,33 @@ +# Agent Note: CI failover runbook — hosted pools → in-house pool + +Status: implemented + +English | [中文](ci-failover-runbook.zh.md) + +## What this is + +The three required Linux jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) resolve their runner pool through the `DSH_CI_FAILOVER` repository variable. Normally the variable is unset and they run on the hosted enterprise 32-core pools. When the hosted pools are degraded (jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails), a repository admin can retarget all three onto the in-house self-hosted pool without merging anything — merging would itself be blocked by the very checks that are failing. + +The in-house pool (`vm-backup`: one 64-core VM, four always-on systemd-managed runner instances, four registered spares) is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. Check its latest run before switching: green standby = verified-yesterday capacity. + +## Switch (repo admin, ~1 minute, no merge) + +1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. +2. Re-run the failed/queued required jobs (Re-run failed jobs on affected PRs, or let new pushes pick it up). +3. That is the entire switch. Under failover the workflow also, automatically: halves `DSH_COVERAGE_MAX_WORKERS` to 12 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 16 (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). + +## Capacity during failover + +Four always-on instances absorb normal PR traffic. If queues build, bring the four registered spares online on the VM (no token needed — they are already registered): + +```bash +for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done +``` + +## Switch back + +Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Stop the spare instances if they were started. + +## Trust boundary + +The variable is repository-admin-only state: a pull request can neither set it nor read a different value into effect, and the expressions live in the base branch's workflow definition. This failover path therefore adds no PR-editable route to the self-hosted pool. (Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism.) diff --git a/.agents/notes/implemented/process/ci-failover-runbook.zh.md b/.agents/notes/implemented/process/ci-failover-runbook.zh.md new file mode 100644 index 0000000000..d7d2628719 --- /dev/null +++ b/.agents/notes/implemented/process/ci-failover-runbook.zh.md @@ -0,0 +1,33 @@ +# Agent Note: CI 故障切换手册 — 托管池 → 自有池 + +Status: implemented + +[English](ci-failover-runbook.md) | 中文 + +## 这是什么 + +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。正常情况下该变量不存在,作业运行在托管的企业级 32 核池上。当托管池发生故障(作业无限排队、企业标签消失或 GitHub 侧容量故障)时,仓库管理员无需合并任何代码即可把三个作业整体切换到公司自有的自托管池——此时合并本身正被这些失败的检查阻塞,任何"先合 PR 再切换"的方案都是死锁。 + +自有池(`vm-backup`:一台 64 核虚拟机,4 个常驻 systemd 管理的运行器实例,另有 4 个已注册备用位)由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。切换前先看该通道最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 + +## 切换步骤(仓库管理员,约 1 分钟,无需合并) + +1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 +2. 对受影响 PR 的失败/排队作业点 Re-run failed jobs(或等新推送自然触发)。 +3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 12、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 16(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 + +## 切换期间的容量 + +4 个常驻实例可承接正常 PR 流量。若出现排队,在虚拟机上把 4 个已注册的备用位拉起(无需 token——它们已注册): + +```bash +for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done +``` + +## 切回 + +删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若启动过备用实例,将其停止。 + +## 信任边界 + +该变量是仅限仓库管理员的状态:拉取请求既不能设置它,也不能让不同的值生效,且表达式存在于基线分支的工作流定义中。因此这条故障切换路径没有增加任何可由 PR 编辑的自托管池访问途径。(运行器侧的强制约束——通过组织级 runner group 把这批运行器限定到 master 引用的工作流——另行跟踪,与本机制互补。) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2666c93b8c..88722804f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,22 @@ jobs: # Three enterprise jobs isolate coverage, static analysis, and the # build-backed consumer tail. The static job publishes its exact build so # consumers do not repeat the longest part of their critical path. + # + # FAILOVER: each Linux enterprise job resolves its pool through the + # DSH_CI_FAILOVER repository variable. Unset (normal), the expressions + # pick the hosted enterprise pools below. Setting the variable to + # 'selfhosted' (repo Settings → Actions → Variables; admin-only, not + # PR-editable, no merge required) retargets all three onto the in-house + # vm-backup pool and re-running the failed jobs is the entire switch — + # see .agents/notes/implemented/process/ci-failover-runbook.md. The + # in-house pool's readiness is re-proven on every master push by the + # serial-linux-selfhosted standby lane below. node-24: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-latest-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / static env: DSH_GATE_CONCURRENCY: '8' @@ -77,17 +90,28 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-24-04-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-24-04-32core-test' }} name: node 24 / coverage env: - DSH_COVERAGE_MAX_WORKERS: '24' + # Failover halves the worker bound: the hosted 32-core runner is + # exclusive to one job, but the failover pool shares one 64-core VM + # across four runner instances, and the timing-sensitive process + # suites have documented aggregate-contention failures. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '12' || '24' }} DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 with: persist-credentials: false + # Skipped under failover: the self-hosted VM's persistent pnpm store + # serves warm installs directly, and this hosted-path restore would + # spend ~52 s pulling ~180 MB into a path pnpm never reads there. - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -118,7 +142,10 @@ jobs: node-24-consumers: needs: node-24 if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-latest-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / snapshots and artifacts env: DSH_ESLINT_CACHE: '1' @@ -126,7 +153,8 @@ jobs: DSH_GATE_CONCURRENCY: '8' DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_PUBLINT_CONCURRENCY: '8' - DSH_SNAPSHOT_MAX_CONCURRENCY: '32' + # Failover halves snapshot concurrency for the shared 64-core VM. + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '16' || '32' }} steps: - uses: actions/checkout@v6 with: @@ -140,7 +168,9 @@ jobs: - name: Restore built tree run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz" + # Skipped under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} From 68e280ce4ff86629ea0443a012d7c7080289ce4d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 05:22:28 +0800 Subject: [PATCH 10/56] docs(ci): make the failover runbook a conforming dated Agent Note The failover runbook landed as .agents/notes/implemented/process/ci-failover-runbook.md, which fails three doc-sync gates: the classification/format gates require a yyyy-mm-dd-topic.md filename and the implemented Agent Note skeleton (Problem/Decision/Alternatives/Consequences), and the bilingual pairing gate requires cross-note link targets to match between the two language sides. Rename to 2026-07-26-ci-failover-runbook.md/.zh.md, reshape both sides into the implemented skeleton (the runbook steps live in bespoke sections under Decision), point the sibling topology note and the ci.yml comment at the dated filename, and make both sides link the canonical .md per the bilingual convention. Re-recorded the i18n pairing records. --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 2 +- ...evidence-based-larger-hosted-runners.zh.md | 2 +- ... 2026-07-26-ci-failover-runbook.i18n.yaml} | 4 +- .../process/2026-07-26-ci-failover-runbook.md | 49 +++++++++++++++++++ .../2026-07-26-ci-failover-runbook.zh.md | 49 +++++++++++++++++++ .../process/ci-failover-runbook.md | 33 ------------- .../process/ci-failover-runbook.zh.md | 33 ------------- .github/workflows/ci.yml | 2 +- 9 files changed, 105 insertions(+), 73 deletions(-) rename .agents/notes/implemented/process/{ci-failover-runbook.i18n.yaml => 2026-07-26-ci-failover-runbook.i18n.yaml} (65%) create mode 100644 .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md create mode 100644 .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md delete mode 100644 .agents/notes/implemented/process/ci-failover-runbook.md delete mode 100644 .agents/notes/implemented/process/ci-failover-runbook.zh.md diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index b2d20fb999..84a10e5ab9 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: 6e989a908b1faa363d04746e4efaa1a77358be9d -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 02c2ab405ec10dd381b051581d72662ec342e21e +2026-07-22-evidence-based-larger-hosted-runners.md: 21e602b2b5850176df981dcf448f4f827b756719 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: ba49ff18ac304f4078d4c8ebfd00bb1a85ada0b3 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 6e989a908b..21e602b2b5 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -52,7 +52,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 02c2ab405e..ba49ff18ac 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](ci-failover-runbook.zh.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml similarity index 65% rename from .agents/notes/implemented/process/ci-failover-runbook.i18n.yaml rename to .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 294ed38ddf..7a65ff5479 100644 --- a/.agents/notes/implemented/process/ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -ci-failover-runbook.md: d22c93fbedd3216e71bc24101dfa06dc606521c2 -ci-failover-runbook.zh.md: d7d26287191165cd3cb2666de4b1c7d6217ba71d +2026-07-26-ci-failover-runbook.md: 9100cf226467d06835478b13c41904bc50270b78 +2026-07-26-ci-failover-runbook.zh.md: 4ec80ae36411335a378f7979b9bca704c17732d0 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md new file mode 100644 index 0000000000..9100cf2264 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -0,0 +1,49 @@ +# Agent Note: CI failover runbook — hosted pools → in-house pool + +Status: implemented + +English | [中文](2026-07-26-ci-failover-runbook.zh.md) + +## Problem + +The three required Linux jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch a repository admin can throw without merging anything. + +## Decision + +Each of the three required Linux jobs resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by a repository admin, all three retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is admin-only repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. + +### What the in-house pool is + +`vm-backup`: one 64-core VM, four always-on systemd-managed runner instances, four registered spares. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. + +### Switch (repo admin, ~1 minute, no merge) + +1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. +2. Re-run the failed/queued required jobs (Re-run failed jobs on affected PRs, or let new pushes pick it up). +3. That is the entire switch. Under failover the workflow also, automatically: halves `DSH_COVERAGE_MAX_WORKERS` to 12 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 16 (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). + +### Capacity during failover + +Four always-on instances absorb normal PR traffic. If queues build, bring the four registered spares online on the VM (no token needed — they are already registered): + +```bash +for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done +``` + +### Switch back + +Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Stop the spare instances if they were started. + +### Trust boundary + +The variable is repository-admin-only state: a pull request can neither set it nor read a different value into effect, and the expressions live in the base branch's workflow definition. This failover path therefore adds no PR-editable route to the self-hosted pool. Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism. + +## Alternatives considered + +**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is admin-controlled state that takes effect on re-run without a merge. + +**Keep the self-hosted pool always in the required path.** Rejected because it trades hosted-pool availability for the in-house VM's, moving a single point of failure rather than adding a fallback. The variable keeps the hosted pools primary and the self-hosted pool a proven, one-action standby. + +## Consequences + +Recovering from a hosted-pool outage is a single admin variable plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md new file mode 100644 index 0000000000..4ec80ae364 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -0,0 +1,49 @@ +# Agent Note: CI 故障切换手册 — 托管池 → 自有池 + +Status: implemented + +[English](2026-07-26-ci-failover-runbook.md) | 中文 + +## 问题 + +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个仓库管理员无需合并任何代码即可触发的开关。 + +## 决策 + +三个必需的 Linux 作业各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由仓库管理员设为 `selfhosted` 时,三者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是仅限管理员的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 + +### 自有池是什么 + +`vm-backup`:一台 64 核虚拟机,4 个常驻 systemd 管理的运行器实例,另有 4 个已注册备用位。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 + +### 切换步骤(仓库管理员,约 1 分钟,无需合并) + +1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 +2. 对受影响 PR 的失败/排队作业点 Re-run failed jobs(或等新推送自然触发)。 +3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 12、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 16(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 + +### 切换期间的容量 + +4 个常驻实例可承接正常 PR 流量。若出现排队,在虚拟机上把 4 个已注册的备用位拉起(无需 token——它们已注册): + +```bash +for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done +``` + +### 切回 + +删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若启动过备用实例,将其停止。 + +### 信任边界 + +该变量是仅限仓库管理员的状态:拉取请求既不能设置它,也不能让不同的值生效,且表达式存在于基线分支的工作流定义中。因此这条故障切换路径没有增加任何可由 PR 编辑的自托管池访问途径。运行器侧的强制约束——通过组织级 runner group 把这批运行器限定到 master 引用的工作流——另行跟踪,与本机制互补。 + +## 曾考虑的替代方案 + +**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是管理员控制的状态,重跑即生效,无需合并。 + +**让自托管池长期处于必需路径中。** 否决,因为这是拿托管池的可用性去换自有虚拟机的可用性,只是搬移了单点故障而非增加回退。该变量让托管池保持主路径,自托管池作为一个经过验证、一步即可启用的热备。 + +## 后果 + +从托管池故障中恢复只需一个管理员变量加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。 diff --git a/.agents/notes/implemented/process/ci-failover-runbook.md b/.agents/notes/implemented/process/ci-failover-runbook.md deleted file mode 100644 index d22c93fbed..0000000000 --- a/.agents/notes/implemented/process/ci-failover-runbook.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: CI failover runbook — hosted pools → in-house pool - -Status: implemented - -English | [中文](ci-failover-runbook.zh.md) - -## What this is - -The three required Linux jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) resolve their runner pool through the `DSH_CI_FAILOVER` repository variable. Normally the variable is unset and they run on the hosted enterprise 32-core pools. When the hosted pools are degraded (jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails), a repository admin can retarget all three onto the in-house self-hosted pool without merging anything — merging would itself be blocked by the very checks that are failing. - -The in-house pool (`vm-backup`: one 64-core VM, four always-on systemd-managed runner instances, four registered spares) is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. Check its latest run before switching: green standby = verified-yesterday capacity. - -## Switch (repo admin, ~1 minute, no merge) - -1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. -2. Re-run the failed/queued required jobs (Re-run failed jobs on affected PRs, or let new pushes pick it up). -3. That is the entire switch. Under failover the workflow also, automatically: halves `DSH_COVERAGE_MAX_WORKERS` to 12 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 16 (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). - -## Capacity during failover - -Four always-on instances absorb normal PR traffic. If queues build, bring the four registered spares online on the VM (no token needed — they are already registered): - -```bash -for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done -``` - -## Switch back - -Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Stop the spare instances if they were started. - -## Trust boundary - -The variable is repository-admin-only state: a pull request can neither set it nor read a different value into effect, and the expressions live in the base branch's workflow definition. This failover path therefore adds no PR-editable route to the self-hosted pool. (Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism.) diff --git a/.agents/notes/implemented/process/ci-failover-runbook.zh.md b/.agents/notes/implemented/process/ci-failover-runbook.zh.md deleted file mode 100644 index d7d2628719..0000000000 --- a/.agents/notes/implemented/process/ci-failover-runbook.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: CI 故障切换手册 — 托管池 → 自有池 - -Status: implemented - -[English](ci-failover-runbook.md) | 中文 - -## 这是什么 - -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。正常情况下该变量不存在,作业运行在托管的企业级 32 核池上。当托管池发生故障(作业无限排队、企业标签消失或 GitHub 侧容量故障)时,仓库管理员无需合并任何代码即可把三个作业整体切换到公司自有的自托管池——此时合并本身正被这些失败的检查阻塞,任何"先合 PR 再切换"的方案都是死锁。 - -自有池(`vm-backup`:一台 64 核虚拟机,4 个常驻 systemd 管理的运行器实例,另有 4 个已注册备用位)由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。切换前先看该通道最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 - -## 切换步骤(仓库管理员,约 1 分钟,无需合并) - -1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 -2. 对受影响 PR 的失败/排队作业点 Re-run failed jobs(或等新推送自然触发)。 -3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 12、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 16(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 - -## 切换期间的容量 - -4 个常驻实例可承接正常 PR 流量。若出现排队,在虚拟机上把 4 个已注册的备用位拉起(无需 token——它们已注册): - -```bash -for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done -``` - -## 切回 - -删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若启动过备用实例,将其停止。 - -## 信任边界 - -该变量是仅限仓库管理员的状态:拉取请求既不能设置它,也不能让不同的值生效,且表达式存在于基线分支的工作流定义中。因此这条故障切换路径没有增加任何可由 PR 编辑的自托管池访问途径。(运行器侧的强制约束——通过组织级 runner group 把这批运行器限定到 master 引用的工作流——另行跟踪,与本机制互补。) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88722804f9..0be7655193 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: # 'selfhosted' (repo Settings → Actions → Variables; admin-only, not # PR-editable, no merge required) retargets all three onto the in-house # vm-backup pool and re-running the failed jobs is the entire switch — - # see .agents/notes/implemented/process/ci-failover-runbook.md. The + # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The # in-house pool's readiness is re-proven on every master push by the # serial-linux-selfhosted standby lane below. node-24: From 498df1d8de66d3f17ed52ec93d7ffa863604cde8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 05:44:35 +0800 Subject: [PATCH 11/56] ci: gate static lane's cache restore under failover; fix runbook recovery steps Review round on the pivoted design: - node-24 (static) kept an unconditional hosted pnpm cache restore while the coverage and consumers lanes skip it under failover. On the self-hosted VM that restore downloads ~180 MB into /home/runner, a path pnpm never reads there, adding latency and contention during an outage. Gate it with the same `vars.DSH_CI_FAILOVER != 'selfhosted'` condition so all three lanes match. - Runbook switch step 2 said "Re-run failed jobs", but the documented indefinite-queue outage leaves jobs queued (not failed), which cannot be re-run in place and do not retarget on variable change. Correct both language sides to cancel the run and re-run all jobs, or push a new commit. - The standby-lane comment still described the switch as a one-line runs-on change; it is now setting the admin-only DSH_CI_FAILOVER variable. --- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../implemented/process/2026-07-26-ci-failover-runbook.md | 2 +- .../process/2026-07-26-ci-failover-runbook.zh.md | 2 +- .github/workflows/ci.yml | 7 +++++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 7a65ff5479..a2725da1b2 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-26-ci-failover-runbook.md: 9100cf226467d06835478b13c41904bc50270b78 -2026-07-26-ci-failover-runbook.zh.md: 4ec80ae36411335a378f7979b9bca704c17732d0 +2026-07-26-ci-failover-runbook.md: db8e0676ecc6eeaea16438e7868ccf9ac43887cc +2026-07-26-ci-failover-runbook.zh.md: b3b4149f460784e88ce03458fc556f402c38fa2f diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 9100cf2264..db8e0676ec 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -19,7 +19,7 @@ Each of the three required Linux jobs resolves its runner pool through the `DSH_ ### Switch (repo admin, ~1 minute, no merge) 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. -2. Re-run the failed/queued required jobs (Re-run failed jobs on affected PRs, or let new pushes pick it up). +2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. 3. That is the entire switch. Under failover the workflow also, automatically: halves `DSH_COVERAGE_MAX_WORKERS` to 12 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 16 (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). ### Capacity during failover diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 4ec80ae364..b3b4149f46 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -19,7 +19,7 @@ Status: implemented ### 切换步骤(仓库管理员,约 1 分钟,无需合并) 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 -2. 对受影响 PR 的失败/排队作业点 Re-run failed jobs(或等新推送自然触发)。 +2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 12、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 16(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 ### 切换期间的容量 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0be7655193..95b54b44f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,8 +55,10 @@ jobs: persist-credentials: false # Pull requests consume the default-branch cache but do not put cache - # compression and upload on the paid latency-critical path. + # compression and upload on the paid latency-critical path. Skipped + # under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -398,7 +400,8 @@ jobs: # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, # continuously proving that environment can take over a required lane if - # the hosted pools degrade (the switch is then a one-line runs-on change). + # the hosted pools degrade (the switch is then setting the admin-only + # DSH_CI_FAILOVER variable — see the failover runbook, no merge required). # Push-triggered, so it always executes the base branch's own workflow # definition — no PR-editable path selects these runners. Non-blocking for # pull requests; no cache steps because the VM's persistent pnpm store and From c3873464baf073dd5988ec0ecd1595ffa46ba874 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:14:28 +0800 Subject: [PATCH 12/56] refactor(scripts): consolidate gate scripts on mdast fences, parseArgs, and globSync Implements the gate-consolidation Agent Note from the NIH dependency audit: - Shared markdownFences helper in scripts/markdown.ts (mdast code-node visit); doc-typecheck and verify-type-equiv extract fences through it; md-fences.ts and the duplicated extractEquivBlocks regex scanner are deleted; markdownProseLines derives fenced lines from parsed code-node positions instead of a second fence regex. - publint-all.ts and verify-built-package-invariants.mjs parse argv with node:util parseArgs instead of hand-stepped parseOptions copies. - Five straggler readdirSync walks become globSync: verify-runtime-closure, dev-web discoverPluginDirs, verify-package-paths realPackageNames, verify-client-domain-graph listSources, publint-all addPath. The dirent-diagnostic walks in check-workspace-constraints.ts and clean.ts stay. Behavior parity verified: pnpm run doc-sync and every rewritten gate produce byte-identical output before and after on this tree. Moves the owning Agent Note proposed -> implemented and re-records its pair. --- ...te-gate-scripts-on-existing-deps.i18n.yaml | 4 +- ...nsolidate-gate-scripts-on-existing-deps.md | 33 +++++++++++ ...lidate-gate-scripts-on-existing-deps.zh.md | 33 +++++++++++ ...nsolidate-gate-scripts-on-existing-deps.md | 38 ------------- ...lidate-gate-scripts-on-existing-deps.zh.md | 38 ------------- scripts/dev-web.ts | 21 ++----- scripts/doc-typecheck.ts | 8 ++- scripts/markdown.ts | 52 +++++++++++++----- scripts/md-fences.ts | 55 ------------------- scripts/publint-all.ts | 27 +++------ scripts/verify-built-package-invariants.mjs | 25 +++------ scripts/verify-client-domain-graph.ts | 18 +++--- scripts/verify-package-paths.ts | 10 +--- scripts/verify-runtime-closure.ts | 22 ++------ scripts/verify-type-equiv.ts | 47 +++++----------- 15 files changed, 163 insertions(+), 268 deletions(-) rename .agents/notes/{proposed => implemented}/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml (59%) create mode 100644 .agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md create mode 100644 .agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md delete mode 100644 .agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md delete mode 100644 .agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md delete mode 100644 scripts/md-fences.ts diff --git a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml similarity index 59% rename from .agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml index 785046f6ce..103c234eec 100644 --- a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-26-consolidate-gate-scripts-on-existing-deps.md: 2b6c2f80b4fc3d3bf818b6789b5f40bb7a61b654 -2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: b20a5bd9ba1661321721c0c9d62de8dc63ec645b +2026-07-26-consolidate-gate-scripts-on-existing-deps.md: 5a7c032bf44a72aa269e0f34e555ed775f6290b4 +2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: c914d2b183c5d6949aa1be384fe5b7562fb1bc1f diff --git a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md new file mode 100644 index 0000000000..5a7c032bf4 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md @@ -0,0 +1,33 @@ +# Agent Note: Consolidate gate scripts on already-present deps and builtins + +Status: implemented + +English | [中文](2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md) + +## Problem + +The `scripts/` gates mostly used the right tools (`node:fs` `globSync` in 15+ gates, mdast/micromark in the markdown gates), but a handful of stragglers hand-rolled what a sibling gate already did with an existing dependency or builtin: + +- **Duplicated fence scanners.** `scripts/md-fences.ts` (~55 lines, consumed by `doc-typecheck.ts`) and `extractEquivBlocks` in `scripts/verify-type-equiv.ts` (~39 lines) were two copies of the same regex line-scanner for fenced code blocks, while `scripts/verify-mermaid.ts` already extracted fences by visiting mdast `code` nodes — and `markdownProseLines` in `scripts/markdown.ts` itself parsed to mdast but then hand-tracked fence state with a second regex. The regex scanners only recognized backtick fences at column 0, so they silently disagreed with the mdast-based gates on tilde and indented fences. +- **Hand-rolled argv parsing.** `parseOptions` in `scripts/publint-all.ts` and its near-identical copy in `scripts/verify-built-package-invariants.mjs` (~26 lines) stepped argv indexes manually, while sibling scripts (`verify-runtime-closure.ts`, `build-exe-for-python-sdk.ts`, `packages/sdk/scripts/src/args.ts`) already used the `node:util` `parseArgs` builtin. +- **Hand-rolled directory walks.** Five sites re-derived nested `readdirSync` walks that `globSync` covers: `verify-runtime-closure.ts` (packages + vendor manifests), `dev-web.ts` `discoverPluginDirs`, `verify-package-paths.ts` `realPackageNames`, `verify-client-domain-graph.ts` `listSources`, and `publint-all.ts` `addPath` (~55–65 lines total). `scripts/package-invariants.ts` shows the one-line `globSync` template. + +No new dependency was needed anywhere; every replacement is an existing devDep or a Node builtin. + +## Decision + +- A shared mdast fence helper, `markdownFences` in `scripts/markdown.ts`, visits `code` nodes for the language, full info string, body, and 1-based opening-fence line; `doc-typecheck.ts` and `verify-type-equiv.ts` extract fences through it. `md-fences.ts` and the duplicated `extractEquivBlocks` scanner are deleted, and `markdownProseLines` derives fenced lines from the parsed `code` nodes' positions instead of a second regex. +- Both CLIs parse argv via `parseArgs`; unknown options and missing values still fail loud, with `parseArgs`'s own error text instead of the bespoke usage strings. +- The five straggler walks use `globSync`. The walks in `check-workspace-constraints.ts` and `clean.ts` stay: they need dirent-level detail to diagnose malformed trees, which glob-by-pattern cannot report. + +## Alternatives considered + +- **A new glob/walking dependency (`tinyglobby`, `fdir`).** Rejected: the builtin already won repo-wide; these were stragglers, not a gap. +- **`p-map` for `publint-all.ts`'s ~19-line ordered worker pool.** Deliberately left out: one new devDep for one small deletion is at the edge of the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md) bar, and the pool's requirements (bounded workers, deterministic order, env override) are documented in the [parallel-gates note](../process/2026-07-06-parallel-pre-push-gates.md). Fold it in only if `p-map` earns a second consumer. +- **Leaving the fence scanners.** Rejected: two drifting copies of a parser beside a third correct implementation is exactly the duplication the shared `markdown.ts` helper exists to prevent, and the column-0-backtick-only limitation was a latent inconsistency between sibling gates. + +## Consequences + +- One fence parser: every markdown gate now classifies fences through mdast, so tilde, indented, and 4-backtick container fences behave identically everywhere. The docs tree contained no fence shape the regex scanners mishandled, so gate results are unchanged on the tree that landed the swap: `pnpm run doc-sync` and each rewritten gate ran before and after with byte-identical output (`doc-typecheck` block/opt-out counts, `verify-type-equiv` match counts, `publint`, `verify-built-package-invariants`, `verify-runtime-closure`, `verify-package-paths`, `verify-client-domain-graph`, and both package-README prose gates). +- `verify-type-equiv` no longer errors on an unterminated fence: mdast closes an unterminated block at end-of-file, so such a block reaches the manifest checks and still fails there as an orphan or drift rather than as a dedicated scanner error. The `doc-typecheck` scanner never had that error path. +- `parseArgs` keeps the last value of a duplicated option instead of erroring and consumes a `--`-prefixed next token as a value; both are dev-tool edge cases the tests don't pin, accepted in exchange for deleting the two bespoke parsers. diff --git a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md new file mode 100644 index 0000000000..c914d2b183 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 把门禁脚本统一到已有依赖与内置模块上 + +Status: implemented + +[English](2026-07-26-consolidate-gate-scripts-on-existing-deps.md) | 中文 + +## 问题 + +`scripts/` 下的门禁大多已经在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本仍在手写同类门禁早已用既有依赖或内置模块完成的事情: + +- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 已经通过访问 mdast `code` 节点来提取代码围栏;`scripts/markdown.ts` 自己的 `markdownProseLines` 也是先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。 +- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)已经在使用 `node:util` 的内置 `parseArgs`。 +- **手写的目录遍历。**五处代码各自重写了 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。 + +所有替换都不需要引入新依赖;每一处替换用的都是既有的 devDependency 或 Node 内置模块。 + +## 决策 + +- `scripts/markdown.ts` 中的共享 mdast 围栏辅助函数 `markdownFences` 访问 `code` 节点,读取语言、完整 info string、块体以及以 1 起始的开围栏行号;`doc-typecheck.ts` 和 `verify-type-equiv.ts` 通过它提取代码围栏。`md-fences.ts` 和重复的 `extractEquivBlocks` 扫描器已删除,`markdownProseLines` 也改为从解析出的 `code` 节点位置推导围栏内的行,而不再用第二个正则。 +- 两个 CLI 都改用 `parseArgs` 解析 argv;未知选项和缺失取值仍然大声失败,只是错误文案换成了 `parseArgs` 自带的文本,而非原先手写的用法字符串。 +- 那五处掉队的目录遍历改用 `globSync`。`check-workspace-constraints.ts` 和 `clean.ts` 中的遍历保留:它们需要 dirent 级别的细节来诊断结构异常的目录树,按模式匹配的 glob 报告不了这些信息。 + +## 曾考虑的替代方案 + +- **新的 glob/目录遍历依赖(`tinyglobby`、`fdir`)。**不予采纳:内置模块已在全仓库范围内胜出;这几处只是掉队者,不是能力缺口。 +- **用 `p-map` 替换 `publint-all.ts` 中约 19 行的有序 worker 池。**刻意未纳入:为一次小删除引入一个新 devDependency,正处在[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)门槛的边缘,而且该池的需求(worker 数量有界、确定性顺序、环境变量覆盖)已记录在[并行 pre-push 门禁决策记录](../process/2026-07-06-parallel-pre-push-gates.md)中。仅当 `p-map` 赢得第二个消费方时再顺带纳入。 +- **保留这两个围栏扫描器。**不予采纳:在第三个正确实现旁边放着两份逐渐漂移的解析器拷贝,正是共享的 `markdown.ts` 辅助函数要防止的那种重复;「只认第 0 列反引号」的限制也是同类门禁之间的潜在不一致。 + +## 后果 + +- 只剩一个围栏解析器:所有 markdown 门禁现在都经由 mdast 归类代码围栏,因此波浪线围栏、缩进围栏和四反引号容器围栏在各处的行为完全一致。文档树中不存在正则扫描器处理有误的围栏形态,所以在落地这次替换的代码树上门禁结果不变:`pnpm run doc-sync` 及每个被改写的门禁在改动前后各跑一遍,输出逐字节相同(`doc-typecheck` 的块数/opt-out 计数、`verify-type-equiv` 的匹配计数、`publint`、`verify-built-package-invariants`、`verify-runtime-closure`、`verify-package-paths`、`verify-client-domain-graph`,以及两个包 README 散文门禁)。 +- `verify-type-equiv` 不再对未闭合的围栏报专门的错误:mdast 会在文件末尾闭合未闭合的代码块,这样的块会进入 manifest 检查,并在那里以孤儿或漂移的形式照样失败,而不是触发专门的扫描器错误。`doc-typecheck` 的扫描器本来就没有这条错误路径。 +- `parseArgs` 对重复出现的选项保留最后一个值而不报错,还会把下一个以 `--` 开头的 token 当作值消费;这两种情况都是测试未固定的开发工具边缘用例,作为删除两份手写解析器的交换被接受。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md b/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md deleted file mode 100644 index 2b6c2f80b4..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: Consolidate gate scripts on already-present deps and builtins - -Status: proposed - -English | [中文](2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md) - -## Problem - -The `scripts/` gates mostly use the right tools (`node:fs` `globSync` in 15+ gates, mdast/micromark in the markdown gates), but a handful of stragglers hand-roll what a sibling gate already does with an existing dependency or builtin: - -- **Duplicated fence scanners.** `scripts/md-fences.ts` (~55 lines, consumed by `doc-typecheck.ts`) and `extractEquivBlocks` in `scripts/verify-type-equiv.ts` (~39 lines) are two copies of the same regex line-scanner for fenced code blocks, while `scripts/verify-mermaid.ts` already extracts fences by visiting mdast `code` nodes via the shared `scripts/markdown.ts` helpers — and `markdownProseLines` in `markdown.ts` itself parses to mdast but then hand-tracks fence state with a second regex. The regex scanners only recognize backtick fences at column 0, so they silently disagree with the mdast-based gates on tilde and indented fences. -- **Hand-rolled argv parsing.** `parseOptions` in `scripts/publint-all.ts` and its near-identical copy in `scripts/verify-built-package-invariants.mjs` (~26 lines) step argv indexes manually, while sibling scripts (`verify-runtime-closure.ts`, `build-exe-for-python-sdk.ts`, `packages/sdk/scripts/src/args.ts`) already use the `node:util` `parseArgs` builtin. -- **Hand-rolled directory walks.** Five sites re-derive nested `readdirSync` walks that `globSync` covers: `verify-runtime-closure.ts` (packages + vendor manifests), `dev-web.ts` `discoverPluginDirs`, `verify-package-paths.ts` `realPackageNames`, `verify-client-domain-graph.ts` `listSources`, and `publint-all.ts` `addPath` (~55–65 lines total). `scripts/package-invariants.ts` shows the one-line `globSync` template. - -No new dependency is needed anywhere; every replacement is an existing devDep or a Node builtin. - -## Proposal - -- Extract a shared ~10–15-line mdast fence helper (visiting `code` nodes for `lang`, `meta`, `value`, `position.start.line`) into `scripts/markdown.ts`; rewrite `doc-typecheck.ts` and `verify-type-equiv.ts` onto it; delete `md-fences.ts` and the duplicated scanner; drop the redundant fence regex in `markdownProseLines`. -- Replace both `parseOptions` copies with `parseArgs`. -- Replace the five straggler walks with `globSync`. Keep the walks in `check-workspace-constraints.ts` and `clean.ts`: they need dirent-level detail to diagnose malformed trees, which glob-by-pattern cannot report. - -## Alternatives considered - -- **A new glob/walking dependency (`tinyglobby`, `fdir`).** Rejected: the builtin already won repo-wide; these are stragglers, not a gap. -- **`p-map` for `publint-all.ts`'s ~19-line ordered worker pool.** Deliberately left out: one new devDep for one small deletion is at the edge of the [dependency policy](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md) bar, and the pool's requirements (bounded workers, deterministic order, env override) are documented in the [parallel-gates note](../../implemented/process/2026-07-06-parallel-pre-push-gates.md). Fold it in only if `p-map` earns a second consumer. -- **Leaving the fence scanners.** Rejected: two drifting copies of a parser beside a third correct implementation is exactly the duplication the shared `markdown.ts` helper exists to prevent, and the column-0-backtick-only limitation is a latent inconsistency between sibling gates. - -## Acceptance criteria - -- `md-fences.ts` is gone; `doc-typecheck` and `verify-type-equiv` extract fences through `scripts/markdown.ts`; `pnpm run doc-sync` passes with unchanged results on the current tree (any delta traces to a fence shape the regex scanners mishandled). -- Both CLIs parse via `parseArgs`; unknown options still fail loud. -- The five walk sites use `globSync`; the gates they feed pass unchanged. - -## Risks - -- Behavioral deltas on pathological markdown: mdast honors tilde/indented fences the regex scanners ignored, so `doc-typecheck`'s opt-out ratio could shift if any stray fence shape exists in the docs tree; verify by running `doc-sync` before/after. -- `parseArgs` keeps the last value of a duplicated option instead of erroring and consumes a `--`-prefixed next token as a value; both are dev-tool edge cases the tests don't pin. diff --git a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md b/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md deleted file mode 100644 index b20a5bd9ba..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: 把门禁脚本统一到已有依赖与内置模块上 - -Status: proposed - -[English](2026-07-26-consolidate-gate-scripts-on-existing-deps.md) | 中文 - -## 问题 - -`scripts/` 下的门禁大多已经在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本仍在手写同类门禁早已用既有依赖或内置模块完成的事情: - -- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 已经通过共享的 `scripts/markdown.ts` 辅助函数访问 mdast `code` 节点来提取代码围栏;`markdown.ts` 自己的 `markdownProseLines` 也是先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。 -- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)已经在使用 `node:util` 的内置 `parseArgs`。 -- **手写的目录遍历。**五处代码各自重写了 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。 - -所有替换都不需要引入新依赖;每一处替换用的都是既有的 devDependency 或 Node 内置模块。 - -## 提案 - -- 在 `scripts/markdown.ts` 中提取一个约 10–15 行的共享 mdast 围栏辅助函数(访问 `code` 节点,读取 `lang`、`meta`、`value`、`position.start.line`);把 `doc-typecheck.ts` 和 `verify-type-equiv.ts` 改写到它上面;删除 `md-fences.ts` 和重复的扫描器;去掉 `markdownProseLines` 中冗余的围栏正则。 -- 用 `parseArgs` 替换两份 `parseOptions` 拷贝。 -- 用 `globSync` 替换那五处掉队的目录遍历。保留 `check-workspace-constraints.ts` 和 `clean.ts` 中的遍历:它们需要 dirent 级别的细节来诊断结构异常的目录树,按模式匹配的 glob 报告不了这些信息。 - -## 曾考虑的替代方案 - -- **新的 glob/目录遍历依赖(`tinyglobby`、`fdir`)。**不予采纳:内置模块已在全仓库范围内胜出;这几处只是掉队者,不是能力缺口。 -- **用 `p-map` 替换 `publint-all.ts` 中约 19 行的有序 worker 池。**刻意未纳入:为一次小删除引入一个新 devDependency,正处在[依赖策略](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md)门槛的边缘,而且该池的需求(worker 数量有界、确定性顺序、环境变量覆盖)已记录在[并行 pre-push 门禁决策记录](../../implemented/process/2026-07-06-parallel-pre-push-gates.md)中。仅当 `p-map` 赢得第二个消费方时再顺带纳入。 -- **保留这两个围栏扫描器。**不予采纳:在第三个正确实现旁边放着两份逐渐漂移的解析器拷贝,正是共享的 `markdown.ts` 辅助函数要防止的那种重复;「只认第 0 列反引号」的限制也是同类门禁之间的潜在不一致。 - -## 验收标准 - -- `md-fences.ts` 已删除;`doc-typecheck` 与 `verify-type-equiv` 通过 `scripts/markdown.ts` 提取代码围栏;`pnpm run doc-sync` 在当前代码树上通过且结果不变(如有差异,必须能追溯到正则扫描器处理有误的某种围栏形态)。 -- 两个 CLI 都改用 `parseArgs` 解析;未知选项仍然大声失败。 -- 五处遍历代码改用 `globSync`;它们供给的门禁保持原样通过。 - -## 风险 - -- 病态 markdown 上的行为差异:mdast 会承认正则扫描器忽略的波浪线围栏和缩进围栏,因此如果文档树中存在任何零散的此类围栏形态,`doc-typecheck` 的 opt-out 比例可能变化;应在改动前后分别运行 `doc-sync` 加以验证。 -- `parseArgs` 对重复出现的选项保留最后一个值而不报错,还会把下一个以 `--` 开头的 token 当作值消费;这两种情况都是测试未固定的开发工具边缘用例。 diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index ac45f2d02e..38b1cffde1 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -17,8 +17,8 @@ * `watch` through API-level inline config (tsdown workspace mode fills inline * keys under each package's file config, and no package config defines it). */ -import { readdirSync, readFileSync } from 'node:fs' -import { join } from 'node:path' +import { globSync, readFileSync } from 'node:fs' +import { dirname, join, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { build } from 'tsdown' @@ -33,20 +33,9 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url)) */ function discoverPluginDirs(): string[] { const dirs: string[] = [] - for (const group of readdirSync(join(repoRoot, 'packages'), { withFileTypes: true })) { - if (!group.isDirectory()) continue - for (const pkg of readdirSync(join(repoRoot, 'packages', group.name), { withFileTypes: true })) { - if (!pkg.isDirectory()) continue - let manifest: { dshClient?: { platform?: unknown } } - try { - manifest = JSON.parse( - readFileSync(join(repoRoot, 'packages', group.name, pkg.name, 'package.json'), 'utf8'), - ) as { dshClient?: { platform?: unknown } } - } catch { - continue // no package.json (support dirs, scratch): not a workspace package - } - if (manifest.dshClient?.platform === 'web') dirs.push(`packages/${group.name}/${pkg.name}`) - } + for (const manifestPath of globSync('packages/*/*/package.json', { cwd: repoRoot }).sort()) { + const manifest = JSON.parse(readFileSync(join(repoRoot, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } } + if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/')) } return dirs } diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 69b9d67411..0f8e2b3df5 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -10,7 +10,7 @@ import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node import { join, relative, resolve } from 'node:path' import ts from 'typescript' import { builtDeclarationPath } from './doc-typecheck-paths.ts' -import { extractFences } from './md-fences.ts' +import { markdownFences } from './markdown.ts' import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts' const root = resolve(import.meta.dirname, '..') @@ -45,8 +45,10 @@ const KIND_BY_INFO: Record = { /** Extract every recognized TypeScript fence from one Markdown file. */ function extractBlocks(absPath: string): Block[] { const file = relative(root, absPath) - return extractFences(absPath, info => KIND_BY_INFO[info] ?? null) - .map(f => ({ file, line: f.line, kind: f.kind, code: f.code })) + return markdownFences(readFileSync(absPath, 'utf8')).flatMap((fence) => { + const kind = KIND_BY_INFO[fence.info] + return kind === undefined ? [] : [{ file, line: fence.line, kind, code: fence.code }] + }) } const configHost: ts.ParseConfigFileHost = { diff --git a/scripts/markdown.ts b/scripts/markdown.ts index 59291bb11c..37a7970df2 100644 --- a/scripts/markdown.ts +++ b/scripts/markdown.ts @@ -21,6 +21,18 @@ export interface MarkdownHeadingLine extends MarkdownProseLine { text: string } +/** One code block from a parsed Markdown source. */ +export interface MarkdownFence { + /** 1-based source line of the opening fence. */ + line: number + /** Info-string language (its first word), null on a bare or indented block. */ + lang: string | null + /** Full info string (e.g. `ts ignore-check`), '' on a bare or indented block. */ + info: string + /** Block body without the fence delimiters. */ + code: string +} + /** Parse GitHub-flavored Markdown with the repository's standard extensions. */ export function parseMarkdown(source: string): Nodes { return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) @@ -38,6 +50,23 @@ export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | v } } +/** + * Extract every parsed code block with its info string, in document order. + * @param source - Markdown source to scan. + * @returns each block's opening line, language, info string, and body. + */ +export function markdownFences(source: string): MarkdownFence[] { + const fences: MarkdownFence[] = [] + visitMarkdown(parseMarkdown(source), (node) => { + if (node.type !== 'code' || node.position === undefined) return + const lang = node.lang ?? null + const meta = node.meta ?? '' + const info = lang === null ? '' : meta === '' ? lang : `${lang} ${meta}` + fences.push({ line: node.position.start.line, lang, info, code: node.value }) + }) + return fences +} + /** Text a reader sees from one Markdown node; raw HTML itself contributes none. */ function renderedText(node: Nodes): string { if (node.type === 'text' || node.type === 'inlineCode') return node.value @@ -115,27 +144,22 @@ function hasRenderedTextOutsideComments(raw: string, ranges: readonly ColumnRang } /** - * Return source lines outside backtick or tilde fences and HTML comments. + * Return source lines outside code blocks and HTML comments. * @param source - Markdown source whose prose should be retained verbatim. * @returns unfenced lines with their original 1-based locations. */ export function markdownProseLines(source: string): MarkdownProseLine[] { - let fence: { marker: '`' | '~'; length: number } | undefined - const kept: MarkdownProseLine[] = [] const rawLines = source.split('\n') const comments = htmlCommentRanges(source, rawLines) + const fenced = new Set() + visitMarkdown(parseMarkdown(source), (node) => { + if (node.type !== 'code' || node.position === undefined) return + for (let line = node.position.start.line; line <= node.position.end.line; line += 1) fenced.add(line) + }) + const kept: MarkdownProseLine[] = [] rawLines.forEach((raw, i) => { - const token = /^ {0,3}(`{3,}|~{3,})/.exec(raw)?.[1] - if (token !== undefined) { - const marker = token[0] as '`' | '~' - if (fence === undefined) { - fence = { marker, length: token.length } - } else if (marker === fence.marker && token.length >= fence.length) { - fence = undefined - } - return - } - if (fence === undefined && hasRenderedTextOutsideComments(raw, comments.get(i + 1))) { + if (fenced.has(i + 1)) return + if (hasRenderedTextOutsideComments(raw, comments.get(i + 1))) { kept.push({ index: i + 1, raw }) } }) diff --git a/scripts/md-fences.ts b/scripts/md-fences.ts deleted file mode 100644 index ad97164369..0000000000 --- a/scripts/md-fences.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Shared fenced-code-block extractor for the Markdown doc gates - * (currently `doc-typecheck.ts`; future Markdown gates can share it). One scanner, per-gate - * classification: each gate maps a fence info string (` ```ts `, - * ` ```yaml ignore-check `, …) to its own kind tag and receives every - * classified block with its 1-based opening-fence line. - */ - -import { readFileSync } from 'node:fs' - -/** One extracted fenced block, classified by the caller's `classify`. */ -export interface Fence { - /** 1-based line of the opening fence. */ - line: number - kind: K - code: string -} - -/** - * Extract every fenced block of `absPath` whose info string `classify` maps - * to a kind. Blocks classified `null` are skipped (their bodies are still - * consumed, so an unrelated fence can never leak into a tracked one). - * - * @param absPath — absolute path of the Markdown file. - * @param classify — info string (trimmed, e.g. `ts ignore-check`) → kind, or - * null for fences this gate does not track. - * @returns the classified blocks in document order. - */ -export function extractFences(absPath: string, classify: (info: string) => K | null): Fence[] { - const lines = readFileSync(absPath, 'utf8').split('\n') - const blocks: Fence[] = [] - let open: { line: number; kind: K; body: string[] } | null = null - let skipping = false - - lines.forEach((raw, i) => { - const fence = /^```(\s*)(\S.*)?$/.exec(raw) - if (!fence) { - if (open) open.body.push(raw) - return - } - if (open) { - blocks.push({ line: open.line, kind: open.kind, code: open.body.join('\n') }) - open = null - return - } - if (skipping) { - skipping = false - return - } - const kind = classify((fence[2] ?? '').trim()) - if (kind !== null) open = { line: i + 1, kind, body: [] } - else skipping = true - }) - return blocks -} diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 2ed1906763..20e4f54780 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -3,18 +3,21 @@ import { globSync, readFileSync, - readdirSync, statSync, } from 'node:fs' import { availableParallelism } from 'node:os' import { dirname, relative, resolve, sep } from 'node:path' +import { parseArgs } from 'node:util' import { publint, type Message, type PackFile } from 'publint' import { formatMessage } from 'publint/utils' const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY' const repositoryRoot = resolve(import.meta.dirname, '..') -const options = parseOptions(process.argv.slice(2)) -const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot) +const { values: options } = parseArgs({ + args: process.argv.slice(2), + options: { 'packages-root': { type: 'string' } }, +}) +const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot) interface PackageTarget { path: string @@ -88,7 +91,9 @@ function publicationFiles(target: PackageTarget): PackFile[] { function addPath(path: string, paths: Set): void { const stat = statSync(path) if (stat.isDirectory()) { - for (const entry of readdirSync(path)) addPath(resolve(path, entry), paths) + for (const entry of globSync('**/*', { cwd: path, withFileTypes: true })) { + if (entry.isFile()) paths.add(resolve(entry.parentPath, entry.name)) + } } else if (stat.isFile()) { paths.add(path) } @@ -144,20 +149,6 @@ function printResult(result: PublintResult): void { if (result.status === 'passed' && result.messages.length === 0) console.log('All good!') } -function parseOptions(args: string[]): Map { - const parsed = new Map() - for (let index = 0; index < args.length; index += 2) { - const name = args[index] - const value = args[index + 1] - if (name !== '--packages-root' || value === undefined || value.startsWith('--')) { - throw new Error(`publint-all: expected [--packages-root PATH], got ${JSON.stringify(args)}.`) - } - if (parsed.has(name)) throw new Error(`publint-all: duplicate option ${name}.`) - parsed.set(name, value) - } - return parsed -} - const packages = workspacePackages() const concurrency = publintConcurrency(packages.length) console.log(`publint-all: linting ${packages.length} package(s) with ${concurrency} worker(s).`) diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index 9c672e05f0..4c0034dce5 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -13,11 +13,15 @@ import { } from 'node:fs' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { parseArgs } from 'node:util' const repositoryRoot = resolve(import.meta.dirname, '..') -const options = parseOptions(process.argv.slice(2)) -const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot) -const loaderUrl = options.get('--loader-url') +const { values: options } = parseArgs({ + args: process.argv.slice(2), + options: { 'packages-root': { type: 'string' }, 'loader-url': { type: 'string' } }, +}) +const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot) +const loaderUrl = options['loader-url'] ?? pathToFileURL(resolve(repositoryRoot, 'vendor/loader/lib/index.js')).href const failures = [] const manifests = globSync('packages/*/*/package.json', { cwd: packagesRoot }).sort() @@ -77,21 +81,6 @@ if (failures.length > 0) { console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`) -function parseOptions(args) { - const allowed = new Set(['--packages-root', '--loader-url']) - const parsed = new Map() - for (let index = 0; index < args.length; index += 2) { - const name = args[index] - const value = args[index + 1] - if (!allowed.has(name) || value === undefined || value.startsWith('--')) { - throw new Error(`verify-built-package-invariants: expected [--packages-root PATH] [--loader-url URL], got ${JSON.stringify(args)}.`) - } - if (parsed.has(name)) throw new Error(`verify-built-package-invariants: duplicate option ${name}.`) - parsed.set(name, value) - } - return parsed -} - function copyDeclaredLibFiles(packageDir, stagedPackageDir, files) { for (const pattern of files) { if (!pattern.startsWith('lib/')) continue diff --git a/scripts/verify-client-domain-graph.ts b/scripts/verify-client-domain-graph.ts index a0520f5fa6..c1d883d6ef 100644 --- a/scripts/verify-client-domain-graph.ts +++ b/scripts/verify-client-domain-graph.ts @@ -14,8 +14,8 @@ * pnpm exec tsx scripts/verify-client-domain-graph.ts */ -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { globSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { join, resolve, sep } from 'node:path' const root = resolve(import.meta.dirname, '..') const CLIENT_DIR = join(root, 'packages/client') @@ -28,15 +28,11 @@ const ASSEMBLY_FILES = new Set(['apply.ts', 'index.ts', 'index.tsx']) interface Violation { file: string; imported: string; reason: string } /** Recursively list .ts/.tsx files under dir (relative paths). */ -function listSources(dir: string, prefix = ''): string[] { - const out: string[] = [] - for (const name of readdirSync(dir)) { - const full = join(dir, name) - const rel = prefix ? `${prefix}/${name}` : name - if (statSync(full).isDirectory()) out.push(...listSources(full, rel)) - else if (/\.tsx?$/.test(name) && !/\.legacy\./.test(name)) out.push(rel) - } - return out +function listSources(dir: string): string[] { + return globSync('**/*.{ts,tsx}', { cwd: dir }) + .map(rel => rel.split(sep).join('/')) + .filter(rel => !/\.legacy\./.test(rel.slice(rel.lastIndexOf('/') + 1))) + .sort() } /** First path segment of a client-relative file, or '' for top-level files. */ diff --git a/scripts/verify-package-paths.ts b/scripts/verify-package-paths.ts index 0cc0f63536..87acb1b34d 100644 --- a/scripts/verify-package-paths.ts +++ b/scripts/verify-package-paths.ts @@ -5,7 +5,7 @@ * outside the check. */ -import { existsSync, readdirSync } from 'node:fs' +import { existsSync, globSync } from 'node:fs' import { resolve } from 'node:path' import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts' @@ -36,12 +36,8 @@ const isExcluded = (p: string): boolean => */ function realPackageNames(): Set { const names = new Set() - const pkgRoot = resolve(root, 'packages') - for (const group of readdirSync(pkgRoot, { withFileTypes: true })) { - if (!group.isDirectory()) continue - for (const pkg of readdirSync(resolve(pkgRoot, group.name), { withFileTypes: true })) { - if (pkg.isDirectory()) names.add(pkg.name) - } + for (const pkg of globSync('packages/*/*', { cwd: root, withFileTypes: true })) { + if (pkg.isDirectory()) names.add(pkg.name) } return names } diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index 34feb3510b..c87d562f59 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -3,8 +3,9 @@ * peer in its dependency graph. With auto peer installation disabled, a missing * root peer can otherwise fail only when Cordis loads the packaged plugin. */ -import { readFile, readdir } from 'node:fs/promises' -import { join, resolve } from 'node:path' +import { globSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' import { parseArgs } from 'node:util' interface PackageManifest { @@ -72,15 +73,9 @@ if (failures.length > 0) { console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`) async function loadWorkspacePackages(): Promise> { - const paths: string[] = [] - for (const group of await childDirectories(join(root, 'packages'))) { - for (const packageDir of await childDirectories(join(root, 'packages', group))) { - paths.push(join(root, 'packages', group, packageDir, 'package.json')) - } - } - for (const packageDir of await childDirectories(join(root, 'vendor'))) { - paths.push(join(root, 'vendor', packageDir, 'package.json')) - } + const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root }) + .sort() + .map(relative => resolve(root, relative)) const result = new Map() for (const path of paths) { const manifest = await loadManifest(path) @@ -89,11 +84,6 @@ async function loadWorkspacePackages(): Promise> { return result } -async function childDirectories(path: string): Promise { - const entries = await readdir(path, { withFileTypes: true }) - return entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort() -} - async function loadManifest(path: string): Promise { return JSON.parse(await readFile(path, 'utf8')) as PackageManifest } diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 58cfea238d..2673306520 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -11,6 +11,7 @@ import { globSync, readFileSync, existsSync } from 'node:fs' import { resolve, sep } from 'node:path' import ts from 'typescript' +import { markdownFences } from './markdown.ts' import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts' const root = resolve(import.meta.dirname, '..') @@ -80,42 +81,24 @@ function blockSymbol(code: string): string | null { /** Extract every source-equivalence block from one Markdown file. */ function extractEquivBlocks(docRel: string): EquivBlock[] { - const text = readFileSync(resolve(root, docRel), 'utf8') - const lines = text.split('\n') const blocks: EquivBlock[] = [] - let open: { line: number; body: string[]; projection?: 'public-api' } | null = null - - for (let i = 0; i < lines.length; i++) { - const raw = lines[i] ?? '' - const fence = /^```(\s*)(\S.*)?$/.exec(raw) - if (!fence) { - if (open) open.body.push(raw) - continue + for (const fence of markdownFences(readFileSync(resolve(root, docRel), 'utf8'))) { + if (fence.info === 'ts type-equiv public-api') { + throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — use the concise \`ts public-api\` fence`) } - if (open) { - const code = open.body.join('\n') - const symbol = blockSymbol(code) - if (!symbol) { - throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`) - } - blocks.push({ - doc: docRel, - line: open.line, - symbol, - code, - ...(open.projection === undefined ? {} : { projection: open.projection }), - }) - open = null - continue + if (fence.info !== 'ts type-equiv' && fence.info !== 'ts public-api') continue + const symbol = blockSymbol(fence.code) + if (symbol === null) { + throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — type-equiv block has no parseable interface/type/class declaration`) } - const info = (fence[2] ?? '').trim() - if (info === 'ts type-equiv public-api') { - throw new Error(`verify-type-equiv: ${docRel}:${i + 1} — use the concise \`ts public-api\` fence`) - } - if (info === 'ts type-equiv') open = { line: i + 1, body: [] } - if (info === 'ts public-api') open = { line: i + 1, body: [], projection: 'public-api' } + blocks.push({ + doc: docRel, + line: fence.line, + symbol, + code: fence.code, + ...(fence.info === 'ts public-api' ? { projection: 'public-api' as const } : {}), + }) } - if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`) return blocks } From 45a5175e441ad073d82a8a0db688aa3572b90057 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:22:13 +0800 Subject: [PATCH 13/56] feat(tool-web): replace the regex HTML-to-markdown converter with turndown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the turndown Agent Note from the NIH dependency audit (full variant, not the minimal entities-only fallback): dsh-tool-web's fetch rendering now converts HTML through turndown + @joplin/turndown-plugin-gfm (atx headings, fenced code, dash bullets, GFM tables/strikethrough) over the real domino DOM, with script/style/noscript removed wholesale. The hand-rolled ~86-line regex converter html.ts and its entity tables are deleted; renderBody wraps the conversion in try/catch falling back to the raw HTML body, because turndown's recursive DOM walk overflows with a RangeError on pathological nesting (measured: 4k levels on the main thread, 8k in a worker) where the regex version could never throw. Closure weight, measured: tool-web IS in the single-exe runtime closure, and the exe asset globs would pack ~7.9 MB of the three new packages — but ~6 MB of that is domino's test corpus, with runtime lib/ at ~550 KB against a ~174 MB artifact (<0.5% either way), so the swap wins. Per testing policy the previously-missing keyless web_fetch snapshot ships in the same change: the acp-agent `web-fetch` scenario boots a new web.cordis.yml overlay (web seam + real dsh-web-fetch-local provider + tool-web fetch-only + a loopback HTTP fixture server on a fixed port serving deterministic HTML with entities, a GFM table, and nesting), so recording and keyless replay both drive the real HTTP fetch and real conversion end to end; the scenario pins the new `web` header class. The Agent Note moves proposed -> implemented and is rewritten per the lifecycle contract (Decision/Consequences/Testing, closure verdict and alternatives recorded); tool-web and acp-agent READMEs updated in both languages and pairs re-recorded. --- ...ndown-for-tool-web-html-markdown.i18n.yaml | 4 +- ...-26-turndown-for-tool-web-html-markdown.md | 37 ++ ...-turndown-for-tool-web-html-markdown.zh.md | 37 ++ ...-26-turndown-for-tool-web-html-markdown.md | 32 -- ...-turndown-for-tool-web-html-markdown.zh.md | 32 -- docs/config-catalog.md | 2 +- examples/acp-agent/README.i18n.yaml | 4 +- examples/acp-agent/README.md | 2 +- examples/acp-agent/README.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 7 + .../tests/snapshots/web-fetch/input.json | 7 + .../tests/snapshots/web-fetch/session.jsonl | 127 +++++ .../snapshots/web-fetch/stdout.expected.jsonl | 4 + .../web-fetch/system-prompt.expected.md | 27 + .../web-fetch/tool-schemas.expected.json | 489 ++++++++++++++++++ .../acp-agent/web-fetch-fixture-server.mjs | 52 ++ examples/acp-agent/web.cordis.snapshot.yml | 31 ++ examples/acp-agent/web.cordis.yml | 21 + examples/package.json | 1 + packages/web/tool-web/README.i18n.yaml | 4 +- packages/web/tool-web/README.md | 4 +- packages/web/tool-web/README.zh.md | 4 +- packages/web/tool-web/package.json | 5 +- packages/web/tool-web/src/fetch.ts | 34 +- packages/web/tool-web/src/html.ts | 86 --- packages/web/tool-web/src/index.ts | 1 - .../web/tool-web/src/turndown-plugin-gfm.d.ts | 12 + packages/web/tool-web/tests/tool-web.spec.ts | 69 +-- pnpm-lock.yaml | 35 ++ 29 files changed, 962 insertions(+), 210 deletions(-) rename .agents/notes/{proposed => implemented}/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml (60%) create mode 100644 .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md create mode 100644 .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md delete mode 100644 .agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md delete mode 100644 .agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md create mode 100644 examples/acp-agent/tests/snapshots/web-fetch/input.json create mode 100644 examples/acp-agent/tests/snapshots/web-fetch/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json create mode 100644 examples/acp-agent/web-fetch-fixture-server.mjs create mode 100644 examples/acp-agent/web.cordis.snapshot.yml create mode 100644 examples/acp-agent/web.cordis.yml delete mode 100644 packages/web/tool-web/src/html.ts create mode 100644 packages/web/tool-web/src/turndown-plugin-gfm.d.ts diff --git a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml similarity index 60% rename from .agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml index ced514a423..60a5d9aca7 100644 --- a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-26-turndown-for-tool-web-html-markdown.md: 7f25e51bf6e6fc9313a880abee737bca80a472af -2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 3a59b08e13fd392e4f34ac543f32f5b4648f3c1c +2026-07-26-turndown-for-tool-web-html-markdown.md: c72decc336055f3b78dafdf98f2be3771b833cdb +2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 30667b62538ec50608cae461b5cdf651b48e2731 diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md new file mode 100644 index 0000000000..c72decc336 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md @@ -0,0 +1,37 @@ +# Agent Note: Replace tool-web's regex HTML-to-markdown converter with turndown + +Status: implemented + +English | [中文](2026-07-26-turndown-for-tool-web-html-markdown.zh.md) + +## Problem + +`dsh-tool-web`'s `src/html.ts` (~86 lines, ~40 lines of dedicated tests; deleted by this change) converted fetched HTML to markdown with regexes: strip script/style/noscript/comments, convert ``/``/`
  • `, decode numeric entities plus a 12-entry named-entity table, collapse whitespace. The module's own JSDoc said "A richer converter can replace it without changing the seam or tool schema", and the README's Known Limitations documented it as "a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost." The [web capability seam note](../architecture/2026-06-24-web-capability-seam.md) assigns HTML→markdown to this package as presentation, so the swap point was exactly here. The converter's output is model-visible on every fetched HTML page; no keyless snapshot exercised `web_fetch`, so no expected outputs pinned it. + +## Decision + +`packages/web/tool-web/src/fetch.ts` owns a module-level [`turndown`](https://github.com/mixmark-io/turndown) instance (`headingStyle: 'atx'`, `codeBlockStyle: 'fenced'`, `bulletListMarker: '-'` — fixed model-facing presentation, not deployment tunables) with `@joplin/turndown-plugin-gfm`'s composite `gfm` plugin for tables/strikethrough and `remove(['script', 'style', 'noscript'])` replacing the old wholesale drops. `renderBody`'s `html` arm calls it in a try/catch falling back to the raw HTML body: the regex version could never throw, while turndown/domino's recursive DOM walk overflows with a `RangeError` at a few thousand nesting levels (measured: 4k throws on the main thread, 8k in a worker thread), and a degraded page beats an error for a body the provider already decoded. `html.ts` and its conversion tests are deleted; the fallback and the status-header/truncation-footer formatting are tested in `tests/tool-web.spec.ts`, and the README's Known Limitations trades the regex-converter caveat for the pathological-nesting fallback. The gfm plugin ships no types; `src/turndown-plugin-gfm.d.ts` declares the one imported export over `@types/turndown` (a devDependency). + +The dependency-weight question the proposal flagged resolves in favor of the swap: `@deepseek-ai/dsh-tool-web` is in the single-file-executable closure ([single-exe note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)), and the exe's asset globs would pack ~7.9 MB of the three packages as published — but ~6 MB of that is `@mixmark-io/domino`'s test corpus (`test/**`), with runtime `lib/` at ~550 KB against a ~174 MB artifact, under 0.5% either way. + +## Snapshot coverage + +The previously-missing keyless `web_fetch` snapshot ships with the change as the acp-agent scenario `web-fetch`: `examples/acp-agent/web.cordis.yml` composes the web seam, the real `dsh-web-fetch-local` provider, `tool-web` with `search: false`, and `web-fetch-fixture-server.mjs` — a loopback HTTP fixture on a fixed port (the fetched URL is part of the recorded transcript) serving deterministic HTML with named entities, a GFM table, and nested formatting. Recording and keyless replay both drive the real HTTP fetch and conversion; the pinned tool result is the turndown output, and the scenario pins the `web` header class (the `web_fetch` schema and guidance). + +## Alternatives considered + +- **`@mozilla/readability` + a DOM.** Solves a different problem (content extraction, not conversion) and drags a heavier DOM dependency; the seam only asks for markdown rendering of whatever the fetch returned. +- **Keep the regex converter.** It was an explicit v1 placeholder per its own JSDoc; keeping it meant model-visible quality (tables, images, nested formatting) stayed lost for the cost of maintaining bespoke entity tables. +- **The minimal `entities`-only variant.** The proposal's fallback position: replace only the entity-decoding third of `html.ts` with the zero-dependency `entities` package, deleting less but avoiding the dependency-weight question. Not taken because the closure math above made the weight immaterial while the full swap deletes the whole hand-rolled converter and its documented quality gaps. +- **`turndown-plugin-gfm` (the original) instead of `@joplin/turndown-plugin-gfm`.** The original is unmaintained (last publish 2018); the Joplin fork is current against turndown 7 and actively released. + +## Consequences + +- **Bought**: full-fidelity model-visible markdown — tables, images, strikethrough, nested emphasis, fenced code blocks, and the complete named-entity set — plus the deletion of the bespoke converter and its entity tables, with the README's regex-converter caveat narrowed to one degenerate case. +- **Paid**: two runtime dependencies (`turndown` → `@mixmark-io/domino`) enter tool-web and therefore the exe closure (~550 KB of runtime code as measured above), and a new failure mode — pathological nesting — is handled by falling back to raw HTML rather than converting. +- Model-visible output changed on every fetched HTML page; nothing pinned the old output, and the new snapshot pins the new one. + +## Testing + +- `packages/web/tool-web/tests/tool-web.spec.ts` covers the turndown conversion surface (entities, links, tables, nesting, script/style/noscript removal) through `renderBody`, and the raw-HTML fallback with a measured reliably-overflowing 20k-level nesting input; per-file coverage on the package src is 100%. +- The `web-fetch` acp-agent snapshot pins the assembled behavior keylessly end to end (real Loader composition, real HTTP fetch, real conversion). diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md new file mode 100644 index 0000000000..30667b6253 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 用 turndown 替换 tool-web 的正则 HTML 转 markdown 转换器 + +Status: implemented + +[English](2026-07-26-turndown-for-tool-web-html-markdown.md) | 中文 + +## 问题 + +`dsh-tool-web` 的 `src/html.ts`(约 86 行,另有约 40 行专属测试;已由本变更删除)曾用正则表达式把抓取到的 HTML 转成 markdown:剥离 script、style、noscript 标签与注释,转换 ``/``/`
  • `,解码数字实体外加一张 12 项的命名实体表,并折叠空白。该模块自身的 JSDoc 写明「A richer converter can replace it without changing the seam or tool schema」,README 的 Known Limitations 章节也把它记载为「a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost」。[web 能力 seam 决策记录](../architecture/2026-06-24-web-capability-seam.md)把 HTML 转 markdown 作为呈现职责划归本包(package),因此替换点恰好就在这里。每个抓取到的 HTML 页面上,该转换器的输出都对模型可见;此前没有任何无密钥快照执行到 `web_fetch`,因此没有预期输出固定它的行为。 + +## 决策 + +`packages/web/tool-web/src/fetch.ts` 持有一个模块级 [`turndown`](https://github.com/mixmark-io/turndown) 实例(`headingStyle: 'atx'`、`codeBlockStyle: 'fenced'`、`bulletListMarker: '-'`——固定的面向模型呈现方式,不是部署可调项),配合 `@joplin/turndown-plugin-gfm` 的组合 `gfm` 插件提供表格/删除线支持,并用 `remove(['script', 'style', 'noscript'])` 替代旧实现的整体剥离。`renderBody` 的 `html` 分支把调用包在 try/catch 中,失败时回退为原始 HTML 主体:正则版本从不可能抛异常,而 turndown/domino 的递归 DOM 遍历在数千层嵌套(实测:主线程 4k 层抛出,worker 线程 8k 层抛出)会以 `RangeError` 栈溢出,对提供方已经解码的主体来说,降级页面好过报错。`html.ts` 及其转换测试已删除;回退路径与状态头、截断页脚的格式化在 `tests/tool-web.spec.ts` 中有测试覆盖,README 的 Known Limitations 用病态嵌套回退条目替换了正则转换器的警示说明。gfm 插件不带类型声明;`src/turndown-plugin-gfm.d.ts` 基于 `@types/turndown`(devDependency)声明了唯一被导入的导出。 + +提案标记的依赖体积问题的裁决结果支持替换:`@deepseek-ai/dsh-tool-web` 在单文件可执行文件闭包内([single-exe 决策记录](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)),可执行文件的资产 glob 会把这三个包按发布原样打入约 7.9 MB——但其中约 6 MB 是 `@mixmark-io/domino` 的测试语料(`test/**`),运行时 `lib/` 仅约 550 KB,相对约 174 MB 的产物,两种口径都不到 0.5%。 + +## 快照覆盖 + +此前缺失的无密钥 `web_fetch` 快照随本变更以 acp-agent 场景 `web-fetch` 落地:`examples/acp-agent/web.cordis.yml` 组合了 web seam、真实的 `dsh-web-fetch-local` 提供方、`search: false` 的 `tool-web`,以及 `web-fetch-fixture-server.mjs`——一个固定端口(抓取的 URL 是录制 transcript(文本记录)的一部分)上的回环 HTTP fixture,提供包含命名实体、GFM 表格与嵌套格式的确定性 HTML。录制与无密钥回放都驱动真实的 HTTP 抓取与转换;固定住的工具结果就是 turndown 的输出,该场景同时固定 `web` header 类(`web_fetch` 的 schema 与指引)。 + +## 曾考虑的替代方案 + +- **`@mozilla/readability` 加一个 DOM。** 它解决的是另一个问题(内容提取,而非格式转换),还会拖入更重的 DOM 依赖;这个 seam 只要求把抓取返回的内容渲染成 markdown。 +- **保留正则转换器。** 按其自身 JSDoc 的说法,它本来就是明确的 v1 占位实现;保留它意味着模型可见的质量(表格、图片、嵌套格式)继续缺失,代价还是维护一套自制实体表。 +- **仅引入 `entities` 的最小变体。** 提案中的退守方案:只用零依赖的 `entities` 包替换 `html.ts` 中的实体解码部分,删得更少但完全避开依赖体积问题。未采纳:上述闭包测算表明体积无关紧要,而完整替换能删掉整个手写转换器及其记录在案的质量缺口。 +- **用原版 `turndown-plugin-gfm` 而非 `@joplin/turndown-plugin-gfm`。** 原版已无人维护(最后发布于 2018 年);Joplin 分叉与 turndown 7 保持同步并持续发布。 + +## 后果 + +- **收益**:模型可见的完整保真 markdown——表格、图片、删除线、嵌套强调、围栏代码块以及完整的命名实体集——并删除了自制转换器及其实体表,README 中的正则转换器警示收窄为一个退化用例。 +- **代价**:两个运行时依赖(`turndown` → `@mixmark-io/domino`)进入 tool-web 进而进入可执行文件闭包(如上实测约 550 KB 运行时代码),并新增一种失败模式——病态嵌套改为回退原始 HTML 而非转换。 +- 每个抓取到的 HTML 页面上模型可见的输出都已变化;旧输出本无任何固定,新快照固定了新输出。 + +## 测试 + +- `packages/web/tool-web/tests/tool-web.spec.ts` 通过 `renderBody` 覆盖 turndown 转换面(实体、链接、表格、嵌套、script/style/noscript 移除),并用实测可稳定溢出的 2 万层嵌套输入覆盖原始 HTML 回退;该包 src 的逐文件覆盖率为 100%。 +- acp-agent 的 `web-fetch` 快照无密钥地端到端固定组装后的行为(真实 Loader 组合、真实 HTTP 抓取、真实转换)。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md b/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md deleted file mode 100644 index 7f25e51bf6..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: Replace tool-web's regex HTML-to-markdown converter with turndown - -Status: proposed - -English | [中文](2026-07-26-turndown-for-tool-web-html-markdown.zh.md) - -## Problem - -`packages/web/tool-web/src/html.ts` (~86 lines, ~40 lines of dedicated tests) converts fetched HTML to markdown with regexes: strip script/style/noscript/comments, convert ``/``/`
  • `, decode numeric entities plus a 12-entry named-entity table, collapse whitespace. The module's own JSDoc says "A richer converter can replace it without changing the seam or tool schema", and the README's Known Limitations documents it as "a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost." The [web capability seam note](../../implemented/architecture/2026-06-24-web-capability-seam.md) assigns HTML→markdown to this package as presentation, so the swap point is exactly here. The converter's output is model-visible on every fetched HTML page; no keyless snapshot currently exercises `web_fetch`, so no expected outputs pin it. - -## Proposal - -Replace `htmlToMarkdown` with `turndown` (`new TurndownService().turndown(html)`), optionally with `turndown-plugin-gfm` for tables. The consumer switch in `fetch.ts` and the status-header/truncation-footer formatting stay. Wrap the call in try/catch falling back to the raw text path: the regex version could never throw; turndown on pathological HTML could. Delete `html.ts` and its conversion tests; keep tests for the fallback and the surrounding formatting. Update the README's Known Limitations to drop the regex-converter caveat. - -If the "deliberately minimal fallback" stance is preferred instead, a minimal variant still deletes the worst part: replace the entity-decoding third of the file (~30 lines: `decodeEntities`, `NAMED_ENTITIES`, `safeFromCodePoint`) with the zero-dependency `entities` package (already in the lockfile transitively), erasing the documented "about a dozen entities" limitation at near-zero risk. - -## Alternatives considered - -- **`@mozilla/readability` + a DOM.** Solves a different problem (content extraction, not conversion) and drags a heavier DOM dependency; the seam only asks for markdown rendering of whatever the fetch returned. -- **Keep the regex converter.** It was an explicit v1 placeholder per its own JSDoc; keeping it means model-visible quality (tables, images, nested formatting) stays lost for the cost of maintaining bespoke entity tables. -- **The minimal `entities`-only variant.** Kept in the proposal as the fallback position; it deletes less but avoids the dependency-weight question entirely. - -## Acceptance criteria - -- `web_fetch` renders tables/nested formatting via turndown (or, minimal variant: decodes all named entities), with the README limitation updated. -- Unit tests cover the fallback path; `pnpm run test` passes for the package. -- A keyless snapshot exercising `web_fetch` markdown rendering is added per testing policy (the missing snapshot coverage is part of the change, and it pins the new output). - -## Risks - -- Model-visible output changes on every fetched HTML page — transcript drift is acceptable pre-release, and nothing currently pins the old output. -- Dependency weight: turndown's one dependency (`@mixmark-io/domino`) is a ~200 KB DOM that would enter the single-file-executable closure if tool-web ships in it ([single-exe note](../../implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)); the minimal `entities` variant avoids this if closure size is the deciding factor. diff --git a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md b/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md deleted file mode 100644 index 3a59b08e13..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: 用 turndown 替换 tool-web 的正则 HTML 转 markdown 转换器 - -Status: proposed - -[English](2026-07-26-turndown-for-tool-web-html-markdown.md) | 中文 - -## 问题 - -`packages/web/tool-web/src/html.ts`(约 86 行,另有约 40 行专属测试)用正则表达式把抓取到的 HTML 转成 markdown:剥离 script、style、noscript 标签与注释,转换 ``/``/`
  • `,解码数字实体外加一张 12 项的命名实体表,并折叠空白。该模块自身的 JSDoc 写明「A richer converter can replace it without changing the seam or tool schema」,README 的 Known Limitations 章节也把它记载为「a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost」。[web 能力 seam 决策记录](../../implemented/architecture/2026-06-24-web-capability-seam.md)把 HTML 转 markdown 作为呈现职责划归本包(package),因此替换点恰好就在这里。每个抓取到的 HTML 页面上,该转换器的输出都对模型可见;当前没有任何无密钥快照执行到 `web_fetch`,因此没有预期输出固定它的行为。 - -## 提案 - -用 `turndown` 替换 `htmlToMarkdown`(`new TurndownService().turndown(html)`),可选择配合 `turndown-plugin-gfm` 支持表格。`fetch.ts` 中的消费方分支与状态头、截断页脚的格式化保持不变。把调用包在 try/catch 中,失败时回退到原始文本路径:正则版本从不可能抛异常,而 turndown 处理病态 HTML 时可能抛出。删除 `html.ts` 及其转换测试;保留回退路径与外围格式化的测试。更新 README 的 Known Limitations 章节,移除正则转换器的警示说明。 - -如果更倾向于「刻意保持最小回退实现」的立场,最小变体仍能删掉最糟的部分:用零依赖的 `entities` 包(已通过传递依赖存在于 lockfile 中)替换文件中占三分之一的实体解码部分(约 30 行:`decodeEntities`、`NAMED_ENTITIES`、`safeFromCodePoint`),以近乎为零的风险抹掉文档记载的「about a dozen entities」限制。 - -## 曾考虑的替代方案 - -- **`@mozilla/readability` 加一个 DOM。** 它解决的是另一个问题(内容提取,而非格式转换),还会拖入更重的 DOM 依赖;这个 seam 只要求把抓取返回的内容渲染成 markdown。 -- **保留正则转换器。** 按其自身 JSDoc 的说法,它本来就是明确的 v1 占位实现;保留它意味着模型可见的质量(表格、图片、嵌套格式)继续缺失,代价还是维护一套自制实体表。 -- **仅引入 `entities` 的最小变体。** 已作为退守方案保留在提案中;它删得更少,但完全避开了依赖体积问题。 - -## 验收标准 - -- `web_fetch` 经由 turndown 渲染表格与嵌套格式(或在最小变体下:解码全部命名实体),README 中的限制说明同步更新。 -- 单元测试覆盖回退路径;该包的 `pnpm run test` 通过。 -- 按测试政策补充一个执行 `web_fetch` markdown 渲染的无密钥快照(缺失的快照覆盖是本变更的一部分,它同时固定新输出)。 - -## 风险 - -- 模型可见的输出在每个抓取到的 HTML 页面上都会变化:预发布阶段的 transcript(文本记录)漂移可以接受,且当前没有任何东西固定旧输出。 -- 依赖体积:turndown 的唯一依赖(`@mixmark-io/domino`)是一个约 200 KB 的 DOM 实现,若 tool-web 进入单文件可执行文件,它会一并进入闭包([single-exe 决策记录](../../implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md));若闭包体积是决定因素,最小的 `entities` 变体可以避开这一点。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9880761894..30499c7df6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1672,7 +1672,7 @@ export interface Config { } ``` -Source: [`packages/web/tool-web/src/index.ts:29`](../packages/web/tool-web/src/index.ts) +Source: [`packages/web/tool-web/src/index.ts:28`](../packages/web/tool-web/src/index.ts) ## `@deepseek-ai/dsh-tool-workflow` diff --git a/examples/acp-agent/README.i18n.yaml b/examples/acp-agent/README.i18n.yaml index 22842fcd04..391967d91c 100644 --- a/examples/acp-agent/README.i18n.yaml +++ b/examples/acp-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 4b3d86b00613cc7c37a8898ef3b39d40a167e66b -README.zh.md: 5bcd85f2b4ae34a11b980bf196d3401f764004d8 +README.md: 0d63ec1f2d9165b9faf0817bd94fbe15b97fa961 +README.zh.md: 0c5f8866ea640843513fd9a4c15a17ed4db59d3b diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 4b3d86b006..0d63ec1f2d 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -9,7 +9,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport ``` -The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, while [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. +The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK, and [`web.cordis.yml`](web.cordis.yml) adds the web seam, the local fetch provider, `web_fetch`, and a loopback HTML fixture server for the web-fetch snapshot. ## Protocol channel diff --git a/examples/acp-agent/README.zh.md b/examples/acp-agent/README.zh.md index 5bcd85f2b4..0c5f8866ea 100644 --- a/examples/acp-agent/README.zh.md +++ b/examples/acp-agent/README.zh.md @@ -9,7 +9,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport ``` -该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 则添加 `run_code` 及其生成的 TypeScript SDK。 +该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK,[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture 服务器。 ## 协议通道 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 8e74711a57..ed4a7d6a58 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -41,6 +41,7 @@ const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml' const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) +const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -110,6 +111,12 @@ const SCENARIOS: Scenario[] = [ { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG }, + // web_fetch markdown rendering end to end: the overlay's loopback fixture + // server supplies deterministic HTML (entities, a GFM table, nesting), the + // REAL local fetch provider retrieves it, and the tool result pins the + // turndown conversion. The fetched URL (fixed port) is part of the recorded + // transcript; replay re-executes the real fetch against the same fixture. + { name: 'web-fetch', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'web', configPath: WEB_CONFIG }, { name: 'workspace-edit', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/web-fetch/input.json b/examples/acp-agent/tests/snapshots/web-fetch/input.json new file mode 100644 index 0000000000..dc1993235d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl new file mode 100644 index 0000000000..c6c34bc8e3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl @@ -0,0 +1,127 @@ +{"type":"session","version":0,"id":"c12fa9af-1042-4a92-9ba4-4a968ff23495","createdAt":1785078727712,"cwd":"/tmp/acp-snap-cwd-hqkZWE","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785078727718,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785078727719,"data":{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785078727721,"data":{"title":"Use the web_fetch tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785078727730,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785078727731,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785078728804,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1785078728805,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785078728943,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785078728989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785078728989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785078728989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785078728990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":12,"time":1785078728990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1785078728990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" web"}}} +{"type":"assistant/chunk","seq":14,"time":1785078729038,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":15,"time":1785078729038,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"etch"}}} +{"type":"assistant/chunk","seq":16,"time":1785078729039,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":17,"time":1785078729039,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":18,"time":1785078729085,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":19,"time":1785078729086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":20,"time":1785078729086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" fetch"}}} +{"type":"assistant/chunk","seq":21,"time":1785078729086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" http"}}} +{"type":"assistant/chunk","seq":22,"time":1785078729086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"://"}}} +{"type":"assistant/chunk","seq":23,"time":1785078729086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"127"}}} +{"type":"assistant/chunk","seq":24,"time":1785078729132,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":25,"time":1785078729133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"0"}}} +{"type":"assistant/chunk","seq":26,"time":1785078729133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":27,"time":1785078729133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"0"}}} +{"type":"assistant/chunk","seq":28,"time":1785078729133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1785078729133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":30,"time":1785078729182,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":31,"time":1785078729182,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"431"}}} +{"type":"assistant/chunk","seq":32,"time":1785078729183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"17"}}} +{"type":"assistant/chunk","seq":33,"time":1785078729183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"/m"}}} +{"type":"assistant/chunk","seq":34,"time":1785078729183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"enu"}}} +{"type":"assistant/chunk","seq":35,"time":1785078729183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".html"}}} +{"type":"assistant/chunk","seq":36,"time":1785078729230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":37,"time":1785078729230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":38,"time":1785078729230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":39,"time":1785078729230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":40,"time":1785078729230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":41,"time":1785078729231,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":42,"time":1785078729276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":43,"time":1785078729277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":44,"time":1785078729277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":45,"time":1785078729277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":46,"time":1785078729322,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":47,"time":1785078729323,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":48,"time":1785078729323,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":49,"time":1785078729323,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":50,"time":1785078729463,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":51,"time":1785078729464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":52,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":53,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"url"}}} +{"type":"assistant/chunk","seq":55,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":57,"time":1785078729557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1785078729557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"http"}}} +{"type":"assistant/chunk","seq":59,"time":1785078729557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"://"}}} +{"type":"assistant/chunk","seq":60,"time":1785078729558,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"127"}}} +{"type":"assistant/chunk","seq":61,"time":1785078729604,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":62,"time":1785078729604,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"0"}}} +{"type":"assistant/chunk","seq":63,"time":1785078729604,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":64,"time":1785078729604,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"0"}}} +{"type":"assistant/chunk","seq":65,"time":1785078729604,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":66,"time":1785078729605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":67,"time":1785078729651,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":68,"time":1785078729652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"431"}}} +{"type":"assistant/chunk","seq":69,"time":1785078729652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"17"}}} +{"type":"assistant/chunk","seq":70,"time":1785078729652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"/m"}}} +{"type":"assistant/chunk","seq":71,"time":1785078729652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"enu"}}} +{"type":"assistant/chunk","seq":72,"time":1785078729652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":".html"}}} +{"type":"assistant/chunk","seq":73,"time":1785078729697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":74,"time":1785078729698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":75,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."}}}} +{"type":"assistant/chunk","seq":76,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}}}} +{"type":"assistant/chunk","seq":77,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}}}} +{"type":"assistant/chunk","seq":78,"time":1785078729804,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":79,"time":1785078729807,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."},{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"} +{"type":"tool/call","seq":80,"time":1785078729809,"data":{"turn":1,"step":1,"callId":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}} +{"type":"tool/result","seq":81,"time":1785078729843,"data":{"turn":1,"step":1,"callId":"call_00_sxjOyfDYN07koiE7jiIa5326","content":[{"type":"text","text":"Fetched http://127.0.0.1:43117/menu.html (HTTP 200)\n\nMenu\n\n# Café menu\n\nPrices include **service & _tax_** — updated daily.\n\n- Espresso\n- Flat white\n\n| Drink | Price |\n| --- | --- |\n| Espresso | €2 |\n| Flat white | €3 |\n\nSee [today’s specials](https://fixture.invalid/specials)."}],"isError":false},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"step/end","seq":82,"time":1785078729847,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":83,"time":1785078729848,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":84,"time":1785078730611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":85,"time":1785078730612,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":86,"time":1785078730770,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":87,"time":1785078730824,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":88,"time":1785078730825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":89,"time":1785078730825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":90,"time":1785078730825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" fetch"}}} +{"type":"assistant/chunk","seq":91,"time":1785078730861,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":92,"time":1785078730862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" URL"}}} +{"type":"assistant/chunk","seq":93,"time":1785078730909,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":94,"time":1785078730956,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":95,"time":1785078731002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":96,"time":1785078731003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":97,"time":1785078731003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":98,"time":1785078731003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":99,"time":1785078731050,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":100,"time":1785078731050,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":101,"time":1785078731050,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":102,"time":1785078731050,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":103,"time":1785078731050,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ve"}}} +{"type":"assistant/chunk","seq":104,"time":1785078731051,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" fetched"}}} +{"type":"assistant/chunk","seq":105,"time":1785078731097,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":106,"time":1785078731140,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":107,"time":1785078731141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":108,"time":1785078731141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":109,"time":1785078731141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":110,"time":1785078731189,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":111,"time":1785078731189,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":112,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":113,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":114,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":115,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":116,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":117,"time":1785078731236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":118,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":119,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":120,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":121,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":239,"outputTokens":34,"cacheReadTokens":5376,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":122,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":123,"time":1785078731283,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":239,"outputTokens":34,"cacheReadTokens":5376,"reasoningTokens":31}},"sourceEventSeqs":[84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122],"surfaceOp":"append"} +{"type":"step/end","seq":124,"time":1785078731286,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":125,"time":1785078731286,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md new file mode 100644 index 0000000000..45705db0a5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md @@ -0,0 +1,27 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-pro model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for example a result from web_search). It returns the page content decoded to text. Cite the URL as a markdown link when you use its content. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json new file mode 100644 index 0000000000..1ee86b38ba --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -0,0 +1,489 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "web_fetch", + "description": "Fetch the content of a specific HTTP(S) URL and return it decoded to text.", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The HTTP(S) URL to fetch." + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/web-fetch-fixture-server.mjs b/examples/acp-agent/web-fetch-fixture-server.mjs new file mode 100644 index 0000000000..34a45fdd15 --- /dev/null +++ b/examples/acp-agent/web-fetch-fixture-server.mjs @@ -0,0 +1,52 @@ +/** + * Deterministic loopback HTTP fixture for the web-fetch snapshot scenario: a + * small HTML page (headings, named entities, a GFM table, nested formatting) + * on a fixed port, so recording and keyless replay drive the REAL + * `dsh-web-fetch-local` transport and `dsh-tool-web` markdown rendering + * without external network. The port is fixed because the fetched URL is part + * of the recorded model transcript. + */ +import { createServer } from 'node:http' + +/** Fixed loopback port the scenario prompt points `web_fetch` at. */ +const PORT = 43117 + +const PAGE = ` +Menu + +

    Café menu

    +

    Prices include service & tax — updated daily.

    +
    • Espresso
    • Flat white
    +
    DrinkPrice
    Espresso€2
    Flat white€3
    +

    See today’s specials.

    + +` + +/** Cordis plugin name. */ +export const name = 'web-fetch-fixture-server' + +/** + * Start the fixture server on 127.0.0.1 and register its shutdown. + * @param ctx - Cordis context; the effect disposes the server with the fiber. + */ +export async function apply(ctx) { + const server = createServer((req, res) => { + if (req.url === '/menu.html') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(PAGE) + return + } + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + res.end('not found') + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(PORT, '127.0.0.1', () => resolve(undefined)) + }) + // The fixture must never hold the process open past protocol shutdown. + server.unref() + ctx.effect(() => () => { + server.close() + server.closeAllConnections() + }, 'web-fetch-fixture-server') +} diff --git a/examples/acp-agent/web.cordis.snapshot.yml b/examples/acp-agent/web.cordis.snapshot.yml new file mode 100644 index 0000000000..015e67e221 --- /dev/null +++ b/examples/acp-agent/web.cordis.snapshot.yml @@ -0,0 +1,31 @@ +# Keyless replay counterpart to web.cordis.yml: the web stack and loopback +# fixture server stay real (the tool call re-executes the actual HTTP fetch and +# markdown rendering); only the model adapter is replaced by replay. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: web + name: '@deepseek-ai/dsh-web' + - id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + - id: web-fetch-fixture + name: './web-fetch-fixture-server.mjs' + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + search: false + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/web.cordis.yml b/examples/acp-agent/web.cordis.yml new file mode 100644 index 0000000000..1ed0b3efba --- /dev/null +++ b/examples/acp-agent/web.cordis.yml @@ -0,0 +1,21 @@ +# Web-fetch composition for the web-fetch snapshot scenario: the web seam, the +# real local HTTP fetch provider, the model-facing web tools (fetch only, so +# the pinned header carries exactly the surface under test), and the loopback +# fixture server the scenario prompt fetches — deterministic content, no +# external network, in recording and replay alike. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: web + name: '@deepseek-ai/dsh-web' + - id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + - id: web-fetch-fixture + name: './web-fetch-fixture-server.mjs' + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + search: false diff --git a/examples/package.json b/examples/package.json index 395c135a2d..3d0cc13718 100644 --- a/examples/package.json +++ b/examples/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", + "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index eb3fa4731d..1e746ed566 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 5e567115c386d14b7e412ed2502e7290826a5e5e -README.zh.md: b17fe4107908381806d4029481bbf03696c4f313 +README.md: 5fe48ced81a2cd02197cf8cc10a7d6567b17ffca +README.zh.md: 34ad08e290166ee6db2cd7b836746541d18aad52 diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 5e567115c3..5fe48ced81 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -11,7 +11,7 @@ Each tool is registered independently; a product that wants only one disables th | Tool | Args | Behavior | |---|---|---| | `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. | -| `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. | +| `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown (turndown with GFM tables/strikethrough); text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. | Both tools opt into concurrent scheduling because provider reads return content without mutating parent-agent state. @@ -126,6 +126,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **`htmlToMarkdown` is a minimal regex converter, not an HTML parser** — it strips script/style/noscript, keeps headings/bullets/links, and decodes about a dozen named entities; tables, images, and nested formatting are lost. +- **HTML→markdown conversion falls back to raw HTML on pathological input** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts fetched HTML through a real DOM, but its recursive walk overflows on absurdly deep nesting (thousands of levels); such a body passes through unconverted rather than erroring ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)). - **The model-facing surface is minimal by design, with promotions deferred** — `max_results` stays a config bound (not a model argument), and `web_fetch` takes only `url` (no `format`/`prompt`/LLM-summarization mode); both are named later steps in [the seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md). - **No web-specific permission policy** — both tools execute without requesting `ctx.approval`; a deployment that needs confirmation must add a `tools/pre-execute` policy, and the package does not define persistent URL/domain grants. diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index b17fe41079..34ad08e290 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -11,7 +11,7 @@ | 工具 | 参数 | 行为 | |---|---|---| | `web_search` | `query`(string) | 发现。返回可选答案与源 URL。`max_results` **不** 面向模型:工具设置上限(`searchMaxResults` 配置,默认 8)并传给 seam。 | -| `web_fetch` | `url`(string) | 获取特定 URL。HTML 主体渲染为近似 markdown 的文本;文本主体原样通过。非 2xx 状态会报告,而非报错。工具调用超时是部署策略(`dsh-timeout-policy`),不是模型参数。 | +| `web_fetch` | `url`(string) | 获取特定 URL。HTML 主体渲染为 markdown(turndown,带 GFM 表格/删除线);文本主体原样通过。非 2xx 状态会报告,而非报错。工具调用超时是部署策略(`dsh-timeout-policy`),不是模型参数。 | 两个工具都选择并发调度,因为提供方读取会返回内容,不会修改父 agent 状态。 @@ -126,6 +126,6 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex ## 已知限制与暂缓事项 -- **`htmlToMarkdown` 是最小正则转换器,不是 HTML parser**:它会移除 script/style/noscript,保留标题/项目符号/链接,并解码约十余个命名 entity;表格、图片与嵌套格式会丢失。 +- **HTML→markdown 转换在病态输入上回退为原始 HTML**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换抓取到的 HTML,但其递归遍历在极深嵌套(数千层)上会栈溢出;此类主体不经转换原样通过,而非报错([决策记录](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 - **面向模型的表层有意保持最小,提升项暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM 摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。 - **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久 URL/domain 授权。 diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index ec1d33f4d4..9e1a54b6cd 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -35,10 +35,13 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@joplin/turndown-plugin-gfm": "^1.0.67", + "schemastery": "^3.18.0", + "turndown": "^7.2.4" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@types/turndown": "^5.0.6", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index cc2ae52970..60c0f33507 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -6,12 +6,29 @@ */ import type { Context } from 'cordis' +import TurndownService from 'turndown' +import { gfm } from '@joplin/turndown-plugin-gfm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web' import { assertNever } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' -import { htmlToMarkdown } from './html.ts' + +/** + * The shared HTML→markdown converter: turndown over its bundled domino DOM, + * with GitHub-flavored tables/strikethrough (`@joplin/turndown-plugin-gfm`). + * The style options are fixed model-facing presentation (matching the repo's + * markdown conventions), not deployment tunables. `remove` drops non-content + * elements wholesale — turndown's default keeps their text. The instance is + * stateless across `turndown()` calls and safe to share. + */ +const turndown = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', + bulletListMarker: '-', +}) +turndown.use(gfm) +turndown.remove(['script', 'style', 'noscript']) /** * Validate value constraints the schema DSL can't express: a non-blank `url`. @@ -30,14 +47,23 @@ export function parseFetchArgs(args: { url: string }): { url: string } { /** * Render a fetched body to model-facing markdown text. * - * @param body - the decoded body; `html` is converted via - * {@link htmlToMarkdown}, `text` passes through verbatim. + * @param body - the decoded body; `html` is converted via turndown, `text` + * passes through verbatim. When turndown throws (deeply pathological HTML + * overflows its recursive DOM walk), the raw HTML passes through instead — + * a degraded page beats an error for a body the provider already decoded. * @returns the text for the tool's output block. */ export function renderBody(body: WebFetchBody): string { switch (body.kind) { case 'html': - return htmlToMarkdown(body.content) + try { + return turndown.turndown(body.content) + } catch { + // turndown's DOM walk recurses per element; pathological nesting (a + // few thousand levels) throws RangeError. Provider errors stay + // structured WebErrors upstream; conversion failure downgrades to raw HTML. + return body.content + } case 'text': return body.content /* v8 ignore next 2 -- WebFetchBody is a closed union; this arm is unreachable and only makes adding a kind a compile error. */ diff --git a/packages/web/tool-web/src/html.ts b/packages/web/tool-web/src/html.ts deleted file mode 100644 index 1d6ffdb9a3..0000000000 --- a/packages/web/tool-web/src/html.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Minimal dependency-free HTML-to-readable-text conversion for `web_fetch`, not a full parser. It - * removes non-content elements and tags, decodes common entities, collapses whitespace, and keeps - * basic headings, lists, and links. A richer converter can replace it without changing the seam or - * tool schema. - * @module @deepseek-ai/dsh-tool-web/html - */ - -/** Decode the handful of HTML entities common in textual content. */ -function decodeEntities(text: string): string { - return text - .replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (match, entity: string) => { - if (entity.startsWith('#x') || entity.startsWith('#X')) { - const code = Number.parseInt(entity.slice(2), 16) - return safeFromCodePoint(code, match) - } - if (entity.startsWith('#')) { - const code = Number.parseInt(entity.slice(1), 10) - return safeFromCodePoint(code, match) - } - return NAMED_ENTITIES[entity] ?? match - }) -} - -const NAMED_ENTITIES: Record = { - amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', - copy: '©', reg: '®', trade: '™', hellip: '…', mdash: '—', ndash: '–', -} - -function safeFromCodePoint(code: number, fallback: string): string { - try { - return String.fromCodePoint(code) - } catch { - // An out-of-range code point (RangeError) is the only failure here; keep the - // original entity text rather than throwing out of pure presentation. - return fallback - } -} - -/** - * Convert an HTML document to a readable markdown-ish text approximation. - * Best-effort and lossy by design — fidelity is the job of a future heavier - * converter, not this fallback. - * - * @param html - the raw HTML source. - * @returns plain text with markdown headings, list bullets, and links; - * whitespace collapsed to at most one blank line and trimmed. - */ -export function htmlToMarkdown(html: string): string { - let text = html - // Drop non-content elements entirely (including their contents). - .replace(/]*>[\s\S]*?<\/script>/gi, '') - .replace(/]*>[\s\S]*?<\/style>/gi, '') - .replace(/]*>[\s\S]*?<\/noscript>/gi, '') - .replace(//g, '') - - // Convert links to markdown before stripping tags. - text = text.replace(/]*\bhref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_match, href: string, label: string) => { - const cleanLabel = label.replace(/<[^>]+>/g, '').trim() - return cleanLabel.length > 0 ? `[${cleanLabel}](${href})` : href - }) - - // Headings → markdown hashes. - text = text.replace(/]*>([\s\S]*?)<\/h\1>/gi, (_match, level: string, body: string) => { - const hashes = '#'.repeat(Number(level)) - return `\n\n${hashes} ${body.replace(/<[^>]+>/g, '').trim()}\n\n` - }) - - // List items → bullets. - text = text.replace(/]*>([\s\S]*?)<\/li>/gi, (_match, body: string) => `\n- ${body.replace(/<[^>]+>/g, '').trim()}`) - - // Block-level breaks become paragraph breaks. - text = text - .replace(/<\/(p|div|section|article|header|footer|tr|table|ul|ol|blockquote)>/gi, '\n\n') - .replace(//gi, '\n') - - // Drop all remaining tags, decode entities, collapse whitespace. - text = text.replace(/<[^>]+>/g, '') - text = decodeEntities(text) - text = text - .replace(/[ \t\f\v]+/g, ' ') - .replace(/ *\n */g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim() - return text -} diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 7096371ed1..e7ac4b2453 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -14,7 +14,6 @@ import { applyWebFetchTool } from './fetch.ts' export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts' export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, renderBody } from './fetch.ts' -export { htmlToMarkdown } from './html.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-web' diff --git a/packages/web/tool-web/src/turndown-plugin-gfm.d.ts b/packages/web/tool-web/src/turndown-plugin-gfm.d.ts new file mode 100644 index 0000000000..66c9d929e4 --- /dev/null +++ b/packages/web/tool-web/src/turndown-plugin-gfm.d.ts @@ -0,0 +1,12 @@ +/** + * Ambient module declaration for `@joplin/turndown-plugin-gfm`, which ships no + * types and has no DefinitelyTyped package. Only the composite `gfm` plugin is + * declared; the package's individual plugins (`tables`, `strikethrough`, …) + * stay undeclared until something imports them. + */ +declare module '@joplin/turndown-plugin-gfm' { + import type TurndownService from 'turndown' + + /** The composite GitHub-flavored-markdown plugin (tables, strikethrough, task lists, highlighted code blocks). */ + export const gfm: TurndownService.Plugin +} diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 093184c4a7..f9ffb1b5c5 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -14,7 +14,6 @@ import { presentSearchCall, presentFetchCall, renderBody, - htmlToMarkdown, WEB_SEARCH_MAX_RESULTS, } from '@deepseek-ai/dsh-tool-web' @@ -82,6 +81,11 @@ describe('search formatting', () => { expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' }) }) + it('falls back to the raw URL as a source label when the URL is unparseable', () => { + const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] }) + expect(out).toContain('[not a url](not a url)') + }) + it('presents a search call as a search-kind card titled by the query', () => { expect(presentSearchCall({ query: 'find me' })).toEqual({ card: 'generic', title: 'find me', kind: 'search', rawInput: 'find me' }) }) @@ -112,6 +116,29 @@ describe('fetch formatting', () => { expect(renderBody({ kind: 'html', content: '

    y

    ' })).toBe('y') }) + it('converts html via turndown: entities, links, tables, nesting; drops script/style/noscript', () => { + expect(renderBody({ + kind: 'html', + content: '

    Tom & Jerry © Résumé

    link', + })).toBe('Tom & Jerry © Résumé\n\n[link](https://a.test)') + expect(renderBody({ kind: 'html', content: '

    Heading

    • one
    • two
    ' })) + .toBe('## Heading\n\n- one\n- two') + expect(renderBody({ kind: 'html', content: '
    AB
    12
    ' })) + .toBe('| A | B |\n| --- | --- |\n| 1 | 2 |') + expect(renderBody({ kind: 'html', content: '

    bold italic

    quoted

    ' })) + .toBe('**bold _italic_**\n\n> quoted') + }) + + it('falls back to the raw html body when turndown throws on pathological nesting', { timeout: 60_000 }, () => { + // Nesting past V8's default stack overflows turndown/domino's recursive + // walk with a RangeError (measured: 4k levels throw on the main thread, + // 8k in a worker); 20k adds margin over either stack size. The raw body + // must pass through instead of throwing. + const depth = 20_000 + const pathological = '
    '.repeat(depth) + 'x' + '
    '.repeat(depth) + expect(renderBody({ kind: 'html', content: pathological })).toBe(pathological) + }) + it('validates url (non-empty), no timeout parameter', () => { expect(() => parseFetchArgs({ url: ' ' })).toThrow('non-empty') expect(parseFetchArgs({ url: 'https://a.test' })).toEqual({ url: 'https://a.test' }) @@ -122,46 +149,6 @@ describe('fetch formatting', () => { }) }) -describe('htmlToMarkdown', () => { - it('drops scripts/styles, keeps text, decodes entities, converts links', () => { - const md = htmlToMarkdown('

    Tom & Jerry

    link') - expect(md).not.toContain('bad()') - expect(md).not.toContain('.x{}') - expect(md).toContain('Tom & Jerry') - expect(md).toContain('[link](https://a.test)') - }) - - it('decodes numeric entities and collapses whitespace', () => { - expect(htmlToMarkdown('

    a'b

    ')).toBe("a'b") - expect(htmlToMarkdown('
    x
    \n\n\n
    y
    ')).toBe('x\n\ny') - }) - - it('decodes hex entities and named entities, and leaves unknown/out-of-range ones intact', () => { - expect(htmlToMarkdown('

    AB

    ')).toBe('AB') - expect(htmlToMarkdown('

    © —

    ')).toBe('© —') - expect(htmlToMarkdown('

    ¬areal;

    ')).toBe('¬areal;') - // An out-of-range code point keeps the original entity text (fromCodePoint fallback). - expect(htmlToMarkdown('

    ')).toBe('�') - expect(htmlToMarkdown('

    ')).toBe('�') - }) - - it('renders a link with an empty label as its bare href', () => { - expect(htmlToMarkdown('')).toBe('https://a.test') - }) - - it('converts headings and list items to markdown', () => { - expect(htmlToMarkdown('

    Heading

    after

    ')).toContain('## Heading') - const list = htmlToMarkdown('
    • one
    • two
    ') - expect(list).toContain('- one') - expect(list).toContain('- two') - }) - - it('falls back to the raw URL as a source label when the URL is unparseable', () => { - const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] }) - expect(out).toContain('[not a url](not a url)') - }) -}) - describe('tool-web registration', () => { it('registers both tools by default', async () => { const { fiber, ctx } = await mountTools() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1946a841bb..430cf6ea0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -523,6 +523,9 @@ importers: '@deepseek-ai/dsh-tool-todo': specifier: workspace:* version: link:../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:* + version: link:../packages/web/tool-web '@deepseek-ai/dsh-tool-workflow': specifier: workspace:* version: link:../packages/workflow/tool-workflow @@ -4259,9 +4262,15 @@ importers: packages/web/tool-web: dependencies: + '@joplin/turndown-plugin-gfm': + specifier: ^1.0.67 + version: 1.0.67 schemastery: specifier: ^3.18.0 version: 3.18.0 + turndown: + specifier: ^7.2.4 + version: 7.2.4 devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4299,6 +4308,9 @@ importers: '@deepseek-ai/dsh-web-search-exa': specifier: workspace:^ version: link:../web-search-exa + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -5971,6 +5983,9 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@joplin/turndown-plugin-gfm@1.0.67': + resolution: {integrity: sha512-FZfW5EZfidhzd1IaY1uxHnIZPTVOxAdleMZ4/1U6Nt5b7+Qj5JThDnaIomuJtetnUBzuRNbe9FWMuqD4B3dlWA==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -6076,6 +6091,9 @@ packages: '@opentelemetry/api': optional: true + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -7005,6 +7023,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -9463,6 +9484,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -10860,6 +10885,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@joplin/turndown-plugin-gfm@1.0.67': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -10951,6 +10978,8 @@ snapshots: - bufferutil - utf-8-validate + '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.29) @@ -11705,6 +11734,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/turndown@5.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -14645,6 +14676,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 From c1153577378f271c1145f12f07185be591193fa2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:37:00 +0800 Subject: [PATCH 14/56] =?UTF-8?q?ci:=20experiment=20=E2=80=94=20Wine-run?= =?UTF-8?q?=20Windows=20blocking=20gates=20on=20a=20Linux=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...27-wine-windows-gates-experiment.i18n.yaml | 6 + ...026-07-27-wine-windows-gates-experiment.md | 44 +++++++ ...-07-27-wine-windows-gates-experiment.zh.md | 44 +++++++ .github/workflows/exp-wine-windows.yml | 123 ++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml create mode 100644 .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md create mode 100644 .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md create mode 100644 .github/workflows/exp-wine-windows.yml diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml new file mode 100644 index 0000000000..eb3909cc4e --- /dev/null +++ b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-27-wine-windows-gates-experiment.md: 9f7856dfef229f8f02c85f5968082a0c857bbc94 +2026-07-27-wine-windows-gates-experiment.zh.md: cb185293d7f22723a96448a774bd27dd31e1bc28 diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md new file mode 100644 index 0000000000..9f7856dfef --- /dev/null +++ b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md @@ -0,0 +1,44 @@ +# Agent Note: Wine-run Windows blocking gates on Linux runners + +Status: proposed + +English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md) + +## Problem + +The pull-request Windows lane exists to prove the two blocking win32 surfaces — the workspace build and the production site — plus an observational portability inventory, and it runs on a dedicated paid Windows larger-runner pool; the master serial reference adds a second hosted Windows job. That pool is the only reason a Windows VM exists anywhere in this pipeline, and its provisioning, pricing, and slow setup dominate the lane's cost. + +The open question: can a plain Linux runner produce an equivalent win32 signal for the blocking surfaces, so the dedicated Windows pool can shrink to a master-only reference or disappear from the pull-request path entirely? + +## Proposal + +[exp-wine-windows.yml](../../../../.github/workflows/exp-wine-windows.yml) (self-path-filtered, plus manual dispatch) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a downloaded win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. + +Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. + +This is deliberately a fidelity probe, not a drop-in replacement: Wine reimplements the Win32 API over a case-sensitive ext4 (NTFS case-insensitivity is not emulated by default), provides no ConPTY, and substitutes its own security-descriptor and `MoveFileExW` semantics — exactly the surfaces the repo's `win32.ts` modules and PTY backend care about. The experiment measures which blocking gates pass, which fail for Wine reasons rather than product reasons, and the wall-clock cost relative to the recorded Windows benchmark lanes. + +Promotion, if the verdict is positive: fold the Wine lane in as the pull-request Windows signal for blocking gates and demote the real-Windows pool to the master serial reference; otherwise record the failure class here and keep the pool. + +## Alternatives considered + +**Keep the dedicated Windows pool (status quo).** It is the baseline being priced; nothing is wrong with its signal, only with paying for a Windows VM pool whose blocking surface is two build commands. + +**A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs. Explored as the sibling experiment branch `exp/kvm-windows-ci`; the two experiments price fidelity against latency. + +**Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`. + +**Windows containers.** Not possible: Windows containers require a Windows host kernel; a hosted Linux runner cannot run them. + +**Dropping the Windows lane.** Rejected — win32 is a first-class product target: the koffi-backed DACL and durable-namespace modules, ConPTY-based PTY sessions, and Windows path policy all ship in `packages/`. + +## Acceptance criteria + +- The workflow completes on `ubuntu-latest` with an independent pass/fail verdict per blocking gate (tsc, tsdown, production site) and a recorded wall-clock comparison against the Windows benchmark lanes. +- A decision is recorded here: promote the lane, keep it as a non-blocking canary, or reject it with the observed failure class. + +## Risks + +- False greens: Wine's case-sensitive filesystem and permissive path handling can pass code that breaks on real NTFS, so this lane can complement but never fully replace a real-kernel check for release qualification. +- False reds: missing or stubbed Win32 APIs under Wine fail gates for non-product reasons, and each such failure costs triage time to classify. +- Throughput: Wine's syscall translation on the 2-core standard runner may push the blocking gates past the paid Windows lane's wall clock, erasing the cost argument; the run records the numbers either way. diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md new file mode 100644 index 0000000000..cb185293d7 --- /dev/null +++ b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -0,0 +1,44 @@ +# Agent Note: 在 Linux runner 上用 Wine 运行 Windows 阻断门禁 + +Status: proposed + +[English](2026-07-27-wine-windows-gates-experiment.md) | 中文 + +## 问题 + +Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表面——workspace 构建与生产站点——外加一份观察性可移植性清单,它运行在一个专用的付费 Windows larger-runner 池上;master 串行参照又增加一个托管 Windows 作业。该池是这条流水线中唯一需要 Windows VM 的理由,而其供给、计价与缓慢的准备阶段主导了该通道的成本。 + +悬而未决的问题是:一台普通 Linux runner 能否为阻断表面产出等效的 win32 信号,让专用 Windows 池收缩为仅 master 的参照、甚至完全退出 pull request 路径? + +## 提案 + +[exp-wine-windows.yml](../../../../.github/workflows/exp-wine-windows.yml)(自身路径过滤,外加手动触发)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:下载的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。 + +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。 + +这刻意是一次保真度探针,而非直接替换:Wine 在大小写敏感的 ext4 之上重实现 Win32 API(默认不模拟 NTFS 的大小写不敏感)、不提供 ConPTY、并用自己的安全描述符与 `MoveFileExW` 语义替代——恰是本仓库 `win32.ts` 模块与 PTY 后端关心的表面。实验度量哪些阻断门禁通过、哪些因 Wine 原因而非产品原因失败,以及相对已记录 Windows 基准通道的墙钟成本。 + +若结论为正则晋升:把 Wine 通道并入为 pull request 的阻断门禁 Windows 信号,将真实 Windows 池降级为 master 串行参照;否则在此记录失败类别并保留该池。 + +## 考虑过的替代方案 + +**保留专用 Windows 池(现状)。** 它正是被计价的基线;其信号没有问题,问题只在于为一个阻断表面仅是两条构建命令的 Windows VM 池付费。 + +**在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装。作为兄弟实验分支 `exp/kvm-windows-ci` 探索;两个实验共同为保真度与延迟定价。 + +**Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 破坏类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索。 + +**Windows 容器。** 不可行:Windows 容器要求 Windows 宿主内核;托管 Linux runner 无法运行。 + +**砍掉 Windows 通道。** 已否决——win32 是一等产品目标:基于 koffi 的 DACL 与持久命名空间模块、基于 ConPTY 的 PTY 会话、以及 Windows 路径策略都随 `packages/` 交付。 + +## 验收标准 + +- 该 workflow 在 `ubuntu-latest` 上完成,对每个阻断门禁(tsc、tsdown、生产站点)给出独立的通过/失败裁决,并记录与 Windows 基准通道的墙钟对比。 +- 在此记录一项决定:晋升该通道、保留为非阻断金丝雀、或以观察到的失败类别否决。 + +## 风险 + +- 假绿:Wine 的大小写敏感文件系统与宽松路径处理可能放过在真实 NTFS 上会坏的代码,因此该通道可以补充、但永远无法完全替代发布资格所需的真实内核检查。 +- 假红:Wine 下缺失或桩化的 Win32 API 会因非产品原因让门禁失败,每次此类失败都要花分诊时间归类。 +- 吞吐:Wine 的系统调用翻译在 2 核标准 runner 上可能让阻断门禁的墙钟超过付费 Windows 通道,抹掉成本论点;无论结果如何,运行都会记录数字。 diff --git a/.github/workflows/exp-wine-windows.yml b/.github/workflows/exp-wine-windows.yml new file mode 100644 index 0000000000..499429f807 --- /dev/null +++ b/.github/workflows/exp-wine-windows.yml @@ -0,0 +1,123 @@ +# EXPERIMENT: run the blocking Windows CI gates on a Linux runner through +# Wine, and execute the gate commands with a real Windows Node.js binary. +# Dependency provisioning happens natively on Linux with +# `supportedArchitectures` extended to win32-x64 so the Windows +# esbuild/rolldown/rollup binaries are present in the store. The pnpm-run/cmd +# shim layer is deliberately bypassed (a Linux install writes POSIX shims +# only), so each gate invokes its tool's JavaScript entrypoint directly — the +# same commands run-gates ultimately spawns. Owning rationale and promotion +# criteria: +# .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md +name: Experiment Wine Windows gates + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/exp-wine-windows.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + PRIMARY_NODE_VERSION: '24' + +jobs: + wine-blocking-gates: + name: wine / blocking windows gates + # Deliberately the cheapest hosted substrate: if Wine holds up here, the + # lane needs no special pool at all. + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + WINEDEBUG: '-all' + WINEARCH: win64 + # Skip Wine Mono / Gecko installers: Node needs neither. + WINEDLLOVERRIDES: 'mscoree,mshtml=' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack and install with win32-x64 artifacts + run: | + corepack enable + # Experiment-only install-time override: also materialize the + # win32-x64 platform packages (@esbuild/win32-x64, rolldown and + # rollup MSVC bindings) that the Windows toolchain resolves at + # runtime. supportedArchitectures is not recorded in the lockfile, + # so --frozen-lockfile stays valid. + cat >> pnpm-workspace.yaml <<'EOF' + + supportedArchitectures: + os: [current, win32] + cpu: [current, x64] + EOF + pnpm install --frozen-lockfile + + - name: Install Wine (64-bit) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends wine64 + WINE_BIN=$(command -v wine || command -v wine64) + echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV" + "$WINE_BIN" --version + + - name: Fetch Windows Node.js + run: | + version=$(curl -fsSL https://nodejs.org/dist/index.json \ + | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version') + echo "Windows Node: $version" + curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \ + "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip" + unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win" + echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV" + + - name: Boot Wine prefix and smoke Windows Node + run: | + "$WINE_BIN" wineboot --init || true + wineserver -w || true + "$WINE_BIN" "$NODE_WIN" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" + + # The continue-on-error gates below mirror ci-windows-blocking + # (scripts/run-gates.ts): `build` = tsc -b + tsdown, `production site` = + # vitepress build. Each reports independently so one failure does not + # hide the others' results; the summary step at the end owns the job + # conclusion. + - name: 'Gate: tsc -b (Windows node under Wine)' + id: tsc + continue-on-error: true + timeout-minutes: 45 + run: '"$WINE_BIN" "$NODE_WIN" node_modules/typescript/bin/tsc -b --pretty false' + + - name: 'Gate: tsdown (Windows node under Wine)' + id: tsdown + continue-on-error: true + timeout-minutes: 30 + run: '"$WINE_BIN" "$NODE_WIN" node_modules/tsdown/dist/run.mjs' + + - name: 'Gate: production site (Windows node under Wine)' + id: site + continue-on-error: true + timeout-minutes: 30 + working-directory: website + run: '"$WINE_BIN" "$NODE_WIN" node_modules/vitepress/bin/vitepress.js build .' + + - name: Report gate outcomes + env: + TSC: ${{ steps.tsc.outcome }} + TSDOWN: ${{ steps.tsdown.outcome }} + SITE: ${{ steps.site.outcome }} + run: | + echo "tsc: $TSC" + echo "tsdown: $TSDOWN" + echo "production site: $SITE" + [ "$TSC" = success ] && [ "$TSDOWN" = success ] && [ "$SITE" = success ] From edcc0540f02a265bbfc75e23e72f61b30edf8f4d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:59:38 +0800 Subject: [PATCH 15/56] ci(exp-wine): install the wine dispatcher package, fall back to the wine64 loader path --- .github/workflows/exp-wine-windows.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/exp-wine-windows.yml b/.github/workflows/exp-wine-windows.yml index 499429f807..94ff45544f 100644 --- a/.github/workflows/exp-wine-windows.yml +++ b/.github/workflows/exp-wine-windows.yml @@ -66,8 +66,19 @@ jobs: - name: Install Wine (64-bit) run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends wine64 - WINE_BIN=$(command -v wine || command -v wine64) + # `wine` is the /usr/bin/wine dispatcher; its dependency pulls the + # wine64 loader. Ubuntu's wine64 package alone leaves nothing on + # PATH (the loader sits at /usr/lib/wine/wine64). + sudo apt-get install -y --no-install-recommends wine + WINE_BIN='' + for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do + if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi + done + if [ -z "$WINE_BIN" ]; then + echo '::error::no wine binary found after install' + dpkg -L wine wine64 2>/dev/null | grep -E '/bin/|wine64$' || true + exit 1 + fi echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV" "$WINE_BIN" --version From 8345d6eae843793664547133aefa32c928a2a7aa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:04:26 +0800 Subject: [PATCH 16/56] =?UTF-8?q?ci(exp-wine):=20route=20wine-node=20stdio?= =?UTF-8?q?=20through=20files=20=E2=80=94=20runner=20pipes=20hit=20EBADF?= =?UTF-8?q?=20at=20Node=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/exp-wine-windows.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/exp-wine-windows.yml b/.github/workflows/exp-wine-windows.yml index 94ff45544f..fdb45712c2 100644 --- a/.github/workflows/exp-wine-windows.yml +++ b/.github/workflows/exp-wine-windows.yml @@ -96,7 +96,20 @@ jobs: run: | "$WINE_BIN" wineboot --init || true wineserver -w || true - "$WINE_BIN" "$NODE_WIN" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" + # Node under Wine cannot attach stdio to the Actions runner's pipes + # (Socket open EBADF at bootstrap), so every invocation runs through + # this wrapper: stdio to a regular file, replayed after exit. + cat > "$RUNNER_TEMP/wine-node.sh" <<'SH' + #!/usr/bin/env bash + set -u + log="$1"; shift + "$WINE_BIN" "$NODE_WIN" "$@" < /dev/null > "$log" 2>&1 + status=$? + tail -n 300 "$log" + exit "$status" + SH + chmod +x "$RUNNER_TEMP/wine-node.sh" + "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" # The continue-on-error gates below mirror ci-windows-blocking # (scripts/run-gates.ts): `build` = tsc -b + tsdown, `production site` = @@ -107,20 +120,20 @@ jobs: id: tsc continue-on-error: true timeout-minutes: 45 - run: '"$WINE_BIN" "$NODE_WIN" node_modules/typescript/bin/tsc -b --pretty false' + run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" node_modules/typescript/bin/tsc -b --pretty false' - name: 'Gate: tsdown (Windows node under Wine)' id: tsdown continue-on-error: true timeout-minutes: 30 - run: '"$WINE_BIN" "$NODE_WIN" node_modules/tsdown/dist/run.mjs' + run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" node_modules/tsdown/dist/run.mjs' - name: 'Gate: production site (Windows node under Wine)' id: site continue-on-error: true timeout-minutes: 30 working-directory: website - run: '"$WINE_BIN" "$NODE_WIN" node_modules/vitepress/bin/vitepress.js build .' + run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" node_modules/vitepress/bin/vitepress.js build .' - name: Report gate outcomes env: From f34396b00db4614124efa66ca3c25b659b059630 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:14:00 +0800 Subject: [PATCH 17/56] =?UTF-8?q?ci(exp-wine):=20hoisted=20node=5Fmodules?= =?UTF-8?q?=20layout=20=E2=80=94=20Wine=20node=20does=20not=20realpath=20p?= =?UTF-8?q?npm=20symlinks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/exp-wine-windows.yml | 34 ++++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/exp-wine-windows.yml b/.github/workflows/exp-wine-windows.yml index fdb45712c2..567e41a447 100644 --- a/.github/workflows/exp-wine-windows.yml +++ b/.github/workflows/exp-wine-windows.yml @@ -50,19 +50,37 @@ jobs: - name: Enable corepack and install with win32-x64 artifacts run: | corepack enable - # Experiment-only install-time override: also materialize the - # win32-x64 platform packages (@esbuild/win32-x64, rolldown and - # rollup MSVC bindings) that the Windows toolchain resolves at - # runtime. supportedArchitectures is not recorded in the lockfile, - # so --frozen-lockfile stays valid. + # Experiment-only install-time overrides. supportedArchitectures + # additionally materializes the win32-x64 platform packages + # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) that the + # Windows toolchain resolves at runtime. nodeLinker: hoisted lays + # node_modules out flat with real files: Windows Node under Wine + # does not realpath pnpm's Unix symlinks, so the default isolated + # layout breaks transitive ESM resolution (tsdown -> ansis, + # vite -> rollup). Neither override is recorded in the lockfile, so + # --frozen-lockfile stays valid. cat >> pnpm-workspace.yaml <<'EOF' + nodeLinker: hoisted supportedArchitectures: os: [current, win32] cpu: [current, x64] EOF pnpm install --frozen-lockfile + - name: Resolve tool entrypoints in the hoisted layout + run: | + resolve() { + local name="$1"; shift + for p in "$@"; do + if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi + done + echo "::error::$name not found at any of: $*"; return 1 + } + resolve TSC_JS node_modules/typescript/bin/tsc + resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs + resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js + - name: Install Wine (64-bit) run: | sudo apt-get update @@ -120,20 +138,20 @@ jobs: id: tsc continue-on-error: true timeout-minutes: 45 - run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" node_modules/typescript/bin/tsc -b --pretty false' + run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false' - name: 'Gate: tsdown (Windows node under Wine)' id: tsdown continue-on-error: true timeout-minutes: 30 - run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" node_modules/tsdown/dist/run.mjs' + run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS"' - name: 'Gate: production site (Windows node under Wine)' id: site continue-on-error: true timeout-minutes: 30 working-directory: website - run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" node_modules/vitepress/bin/vitepress.js build .' + run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build .' - name: Report gate outcomes env: From 241a7e6c72854d2bf57b6280d849338961ff6f85 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:23:25 +0800 Subject: [PATCH 18/56] =?UTF-8?q?ci(exp-wine):=20pre-create=20the=20vue=20?= =?UTF-8?q?link=20VitePress=20needs=20=E2=80=94=20Wine=20cannot=20create?= =?UTF-8?q?=20Windows=20symlinks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/exp-wine-windows.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/exp-wine-windows.yml b/.github/workflows/exp-wine-windows.yml index 567e41a447..9ebc3ccc79 100644 --- a/.github/workflows/exp-wine-windows.yml +++ b/.github/workflows/exp-wine-windows.yml @@ -80,6 +80,13 @@ jobs: resolve TSC_JS node_modules/typescript/bin/tsc resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js + # VitePress links vue into the site's node_modules at build time; + # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows + # pre-existing Unix ones, so lay the link down host-side. + if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then + mkdir -p website/node_modules + ln -s ../../node_modules/vue website/node_modules/vue + fi - name: Install Wine (64-bit) run: | From ebdcb5776a1a88d2edd68a3724a151f57554d89f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:02:39 +0800 Subject: [PATCH 19/56] fix(scripts): address review findings on the gate consolidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - publint-all: the recursive publication view uses readdirSync {recursive} again instead of globSync('**/*') — the glob skips dot-prefixed segments (verified empirically), but npm pack publishes dotfiles inside included directories, so hidden exports were reported missing and other hidden files escaped validation - markdown.ts/verify-type-equiv: markdownFences now reports whether a closing delimiter terminates the block (mdast silently closes an unterminated fence at EOF), and verify-type-equiv rejects unclosed type-equivalence fences again — the Agent Note claimed such a block still fails at the manifest checks, but its comparisons can succeed - Agent Note EN+ZH: record the restored rejection; rewrite the zh Problem section into past tense to match the English side's shipped reality; pair re-recorded --- ...onsolidate-gate-scripts-on-existing-deps.i18n.yaml | 4 ++-- ...07-26-consolidate-gate-scripts-on-existing-deps.md | 2 +- ...26-consolidate-gate-scripts-on-existing-deps.zh.md | 10 +++++----- scripts/markdown.ts | 11 ++++++++++- scripts/publint-all.ts | 6 +++++- scripts/verify-type-equiv.ts | 3 +++ 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml index ec46202005..8a52737dfc 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-26-consolidate-gate-scripts-on-existing-deps.md: 31823979e16544a77284eeeab02983c6090cbb51 -2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: 366fd5acff0ec4ddaf2dffa2ec373c90d2e964f3 +2026-07-26-consolidate-gate-scripts-on-existing-deps.md: 6370c8f92eff7296327e941e698ec4f733100bb2 +2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: 3587c92e0e9655d8b38d24d184c1c68c44b131d4 diff --git a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md index 31823979e1..6370c8f92e 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md @@ -29,5 +29,5 @@ No new dependency was needed anywhere; every replacement is an existing devDep o ## Consequences - One fence parser: every markdown gate now classifies fences through mdast, so tilde, indented, and 4-backtick container fences behave identically everywhere. The docs tree contained no fence shape the regex scanners mishandled, so gate results are unchanged on the tree that landed the swap: `pnpm run doc-sync` and each rewritten gate ran before and after with byte-identical output (`doc-typecheck` block/opt-out counts, `verify-type-equiv` match counts, `publint`, `verify-built-package-invariants`, `verify-runtime-closure`, `verify-package-paths`, `verify-client-domain-graph`, and both package-README prose gates). -- `verify-type-equiv` no longer errors on an unterminated fence: mdast closes an unterminated block at end-of-file, so such a block reaches the manifest checks and still fails there as an orphan or drift rather than as a dedicated scanner error. The `doc-typecheck` scanner never had that error path. +- `verify-type-equiv` still rejects an unterminated type-equivalence fence: mdast silently closes an unterminated block at end-of-file (its comparisons could then pass), so the shared helper reports whether a closing delimiter exists and the gate errors on an unclosed block, preserving the removed scanner's rejection. The `doc-typecheck` scanner never had that error path. - `parseArgs` keeps the last value of a duplicated option instead of erroring — a dev-tool edge case the tests don't pin, accepted in exchange for deleting the two bespoke parsers. (Strict mode still rejects a `--`-prefixed token where a value is expected, matching the replaced parsers.) diff --git a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md index 366fd5acff..3587c92e0e 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -`scripts/` 下的门禁大多已经在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本仍在手写同类门禁早已用既有依赖或内置模块完成的事情: +`scripts/` 下的门禁大多本已在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本曾手写同类门禁早已用既有依赖或内置模块完成的事情: -- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 已经通过访问 mdast `code` 节点来提取代码围栏;`scripts/markdown.ts` 自己的 `markdownProseLines` 也是先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。 -- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)已经在使用 `node:util` 的内置 `parseArgs`。 -- **手写的目录遍历。**五处代码各自重写了 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。 +- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)曾是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 早已通过访问 mdast `code` 节点来提取代码围栏;`scripts/markdown.ts` 自己的 `markdownProseLines` 也曾先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。 +- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)曾手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)早已在使用 `node:util` 的内置 `parseArgs`。 +- **手写的目录遍历。**五处代码曾各自重写 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。 所有替换都不需要引入新依赖;每一处替换用的都是既有的 devDependency 或 Node 内置模块。 @@ -29,5 +29,5 @@ Status: implemented ## 后果 - 只剩一个围栏解析器:所有 markdown 门禁现在都经由 mdast 归类代码围栏,因此波浪线围栏、缩进围栏和四反引号容器围栏在各处的行为完全一致。文档树中不存在正则扫描器处理有误的围栏形态,所以在落地这次替换的代码树上门禁结果不变:`pnpm run doc-sync` 及每个被改写的门禁在改动前后各跑一遍,输出逐字节相同(`doc-typecheck` 的块数/opt-out 计数、`verify-type-equiv` 的匹配计数、`publint`、`verify-built-package-invariants`、`verify-runtime-closure`、`verify-package-paths`、`verify-client-domain-graph`,以及两个包 README 散文门禁)。 -- `verify-type-equiv` 不再对未闭合的围栏报专门的错误:mdast 会在文件末尾闭合未闭合的代码块,这样的块会进入 manifest 检查,并在那里以孤儿或漂移的形式照样失败,而不是触发专门的扫描器错误。`doc-typecheck` 的扫描器本来就没有这条错误路径。 +- `verify-type-equiv` 仍然拒绝未闭合的类型等价围栏:mdast 会在文件末尾静默闭合未闭合的代码块(其比较随后可能通过),因此共享辅助函数会报告闭合定界符是否存在,门禁在块未闭合时报错,保留了被删扫描器的这条拒绝路径。`doc-typecheck` 的扫描器本来就没有这条错误路径。 - `parseArgs` 对重复出现的选项保留最后一个值而不报错——一个测试未固定的开发工具边缘用例,作为删除两份手写解析器的交换被接受。(严格模式下,需要取值处遇到以 `--` 开头的 token 仍会拒绝,与被替换的解析器行为一致。) diff --git a/scripts/markdown.ts b/scripts/markdown.ts index 37a7970df2..1d40e1d8bb 100644 --- a/scripts/markdown.ts +++ b/scripts/markdown.ts @@ -31,6 +31,12 @@ export interface MarkdownFence { info: string /** Block body without the fence delimiters. */ code: string + /** + * Whether a closing fence delimiter terminates the block — mdast silently + * closes an unterminated fence at end of file. False on indented + * (non-fenced) blocks, whose end line is code. + */ + closed: boolean } /** Parse GitHub-flavored Markdown with the repository's standard extensions. */ @@ -56,13 +62,16 @@ export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | v * @returns each block's opening line, language, info string, and body. */ export function markdownFences(source: string): MarkdownFence[] { + const lines = source.split('\n') const fences: MarkdownFence[] = [] visitMarkdown(parseMarkdown(source), (node) => { if (node.type !== 'code' || node.position === undefined) return const lang = node.lang ?? null const meta = node.meta ?? '' const info = lang === null ? '' : meta === '' ? lang : `${lang} ${meta}` - fences.push({ line: node.position.start.line, lang, info, code: node.value }) + const endLine = lines[node.position.end.line - 1] ?? '' + const closed = /^ {0,3}(`{3,}|~{3,})\s*$/.test(endLine) + fences.push({ line: node.position.start.line, lang, info, code: node.value, closed }) }) return fences } diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 20e4f54780..b6ddba1451 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -2,6 +2,7 @@ import { globSync, + readdirSync, readFileSync, statSync, } from 'node:fs' @@ -91,7 +92,10 @@ function publicationFiles(target: PackageTarget): PackFile[] { function addPath(path: string, paths: Set): void { const stat = statSync(path) if (stat.isDirectory()) { - for (const entry of globSync('**/*', { cwd: path, withFileTypes: true })) { + // readdirSync, not globSync: `**/*` skips dot-prefixed segments, but npm + // pack publishes dotfiles inside included directories, and this view must + // match what npm publishes. + for (const entry of readdirSync(path, { recursive: true, withFileTypes: true })) { if (entry.isFile()) paths.add(resolve(entry.parentPath, entry.name)) } } else if (stat.isFile()) { diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index edc1f87974..56d7e25cf5 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -88,6 +88,9 @@ function extractEquivBlocks(docRel: string): EquivBlock[] { throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — use the concise \`ts public-api\` fence`) } if (fence.info !== 'ts type-equiv' && fence.info !== 'ts public-api') continue + if (!fence.closed) { + throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — unterminated type-equivalence fence (missing closing \`\`\`)`) + } const symbol = blockSymbol(fence.code) if (symbol === null) { throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — type-equiv block has no parseable interface/type/class declaration`) From 3ee2982f853946cef8f567fa5c9207f3306e2d42 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 27 Jul 2026 12:02:28 +0800 Subject: [PATCH 20/56] optimize chat ui --- .../2026-07-23-toolview-dissolution.i18n.yaml | 4 +- .../2026-07-23-toolview-dissolution.md | 2 +- .../2026-07-23-toolview-dissolution.zh.md | 2 +- ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 14 +- .../2026-07-23-web-assistant-markdown.zh.md | 14 +- ...-07-27-user-message-icon-actions.i18n.yaml | 6 + .../2026-07-27-user-message-icon-actions.md | 27 +++ ...2026-07-27-user-message-icon-actions.zh.md | 27 +++ .../ui-conversation/src/client/apply.ts | 3 +- .../src/client/chat/ChatView.module.css | 12 +- .../src/client/chat/MessageItem.module.css | 43 +++- .../src/client/chat/MessageItem.tsx | 81 ++++++- .../src/client/skeleton/InputBar.module.css | 2 +- .../client/toolviews/bash-sample.module.css | 49 ++-- .../src/client/toolviews/bash-sample.tsx | 53 +++-- .../tests/chat-branch-tails.spec.tsx | 80 ++++++- .../tests/chat-code-subcalls.spec.tsx | 5 +- .../tests/chat-stats-bash-sample.spec.tsx | 4 +- .../tests/chat-toolview-slot.spec.tsx | 1 + .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/coverage-tails.spec.tsx | 51 +++-- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/markdown/CodeBlock.module.css | 79 ++++++- .../ui-primitives/src/markdown/CodeBlock.tsx | 80 +++++-- .../src/markdown/MarkdownText.module.css | 213 +++++++++++++----- .../ui-primitives/tests/code-block.spec.tsx | 73 +++++- .../ui-primitives/tests/markdown.spec.tsx | 4 +- packages/client/ui-theme/src/styles/base.css | 2 + 31 files changed, 759 insertions(+), 186 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml index 6de82d1c9b..2cba925d67 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-toolview-dissolution.md: a420c5945d0272cf8087d5f623e9c383c286d7c2 -2026-07-23-toolview-dissolution.zh.md: 47c1f392f5f7ddbf4e6c686b2574faa7987e6126 +2026-07-23-toolview-dissolution.md: 80c2688b152d1afe1236d4815633a5bf024db1d2 +2026-07-23-toolview-dissolution.zh.md: 928c5f445d601b2246d3ae2f9360232643814468 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index a420c5945d..80c2688b15 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -14,7 +14,7 @@ After the view ring dissolved into the slot system, the client kept exactly one The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. -Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. +Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`, with a scoped badge only in child sessions). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index 47c1f392f5..928c5f445d 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -14,7 +14,7 @@ Status: implemented 工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 -落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions` 读 `parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 +落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions` 读 `parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`,scoped badge 仅出现在子会话)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 1ff9ecac7d..1f52492649 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-web-assistant-markdown.md: ce98a16fa43e2743c18826ee7f2344c38e7c70e7 -2026-07-23-web-assistant-markdown.zh.md: 0d6fd2f9e6b91f76830586ecf4c29774e5d6978a +2026-07-23-web-assistant-markdown.md: 38d193271d88b3a8f32ba1b191e8a6d432176281 +2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index ce98a16fa4..38d193271d 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -12,13 +12,17 @@ The Web conversation preserves assistant Markdown source through session events, `@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. -`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without `dangerouslySetInnerHTML`, raw-HTML parsing, or syntax highlighting. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser is part of the initial browser bundle. +`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. + +Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Citation pills, KaTeX, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers are out of scope until matching product DOM exists; GFM task lists keep native checkboxes. + +The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle. ## Untrusted output policy -Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. +Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). -The renderer uses existing `--dsw-*` typography and color tokens. Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. +Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. ## Alternatives considered @@ -30,6 +34,8 @@ The renderer uses existing `--dsw-*` typography and color tokens. Fenced code an **Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies. +**Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. + ## Consequences -Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. The initial Web shell grows by the Markdown parser and GFM runtime, and future extensions such as syntax highlighting or remote media require a separate bundle and security decision. +Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, and shiki allowlist; cite/math/anchor/thinking-small surfaces remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index 0d6fd2f9e6..be3cd041c6 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -12,13 +12,17 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd `@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 -`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它支持 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,但不使用 `dangerouslySetInnerHTML`,不解析原始 HTML,也不进行语法高亮。`ui-primitives` 显式声明该依赖;由于这一纯库由 Web shell 预置,解析器会成为初始浏览器 bundle 的一部分。 +`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 + +视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。引用胶囊、KaTeX、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记均不在范围内,直至存在匹配的产品 DOM;GFM 任务列表继续使用原生复选框。 + +该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。 ## 不受信任输出策略 -assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。 +assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 -渲染器使用现有的 `--dsw-*` 排版与颜色 token。围栏代码块与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 +围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 ## 考虑过的替代方案 @@ -30,6 +34,8 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S **通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。 +**移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 + ## 后果 -assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。初始 Web shell 的体积会因加入 Markdown 解析器与 GFM 运行时而增大;语法高亮或远程媒体等后续扩展需要另行作出 bundle 与安全决策。 +assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时与 shiki 允许列表;cite/math/anchor/thinking-small 表层仍暂缓。 diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml new file mode 100644 index 0000000000..52fa9a6cb3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-27-user-message-icon-actions.md: 45856e1ee093b1bfeaebfe67339aec7b6d2dd694 +2026-07-27-user-message-icon-actions.zh.md: ea87b8036ee91e8998f1e45dbea17f9ee76c244c diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md new file mode 100644 index 0000000000..45856e1ee0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md @@ -0,0 +1,27 @@ +# Agent Note: User-message IconActions under the bubble + +Status: implemented + +English | [中文](2026-07-27-user-message-icon-actions.zh.md) + +## Problem + +The chat user bubble had no under-bubble action chrome. The Harness design (figma `User_Bubble/message_container`) shows three IconActions — copy, branch in new chat, and edit — right-aligned under the bubble, matching the product action-bar pattern used elsewhere. + +## Decision + +`MessageItem` owns the actions for `kind: 'user'` only. Layout is a column (`align-items: flex-end`, 6px gap): bubble, then a 28px action row with 10px gaps and 28px circular icon buttons (`IconCopyOutline16`, `IconBranchOutline16`, `IconEditOutline16`). Tooltips carry Chinese labels. The row stays `opacity: 0` until the user row is hovered or focus-within, per the [web styling](../../../../docs/web-styling.md) message action-bar rule. + +Copy writes the bubble's joined text blocks to the clipboard (`navigator.clipboard.writeText`, with an `execCommand` fallback). Branch and edit are present chrome with no handlers yet — they reserve the design seats without inventing session-fork or edit-resubmit behavior. + +Steering bubbles keep the badge-only form and do not show these actions. + +## Alternatives considered + +**Wire branch/edit to real session fork and draft-edit now.** Rejected for this change: those product flows are not specified; shipping inert buttons matches the requested scope and avoids half-built mutation paths. + +**Always-visible actions (no hover fade).** Rejected against the standing action-bar rule; the figma node shows the resting chrome, not the idle-hidden state the style guide requires. + +## Consequences + +User messages expose copy immediately; branch/edit remain clickable stubs until a later decision owns their behavior. Tests pin the three buttons, copy payload, and steering exclusion. diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md new file mode 100644 index 0000000000..ea87b8036e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 用户消息气泡下方的 IconActions + +Status: implemented + +[English](2026-07-27-user-message-icon-actions.md) | 中文 + +## 问题 + +聊天用户气泡下方没有操作栏。Harness 设计稿(figma `User_Bubble/message_container`)在气泡下方右对齐展示三个 IconActions——复制、在新对话中分支、编辑——与产品其他位置使用的操作栏模式一致。 + +## 决策 + +仅当 `kind: 'user'` 时,`MessageItem` 拥有这些操作。布局为纵向列(`align-items: flex-end`,间距 6px):先是气泡,再是高度 28px 的操作行;行内间距 10px,圆形图标按钮尺寸为 28px(`IconCopyOutline16`、`IconBranchOutline16`、`IconEditOutline16`)。Tooltip 承载中文标签。按 [Web 样式](../../../../docs/web-styling.md) 的消息操作栏规则,该行保持 `opacity: 0`,直到用户行被悬停或处于 focus-within 状态。 + +复制将气泡内拼接后的文本块写入剪贴板(`navigator.clipboard.writeText`,并以 `execCommand` 作为回退)。分支与编辑目前仅有外观、尚无处理函数——它们预留设计席位,但不发明会话 fork 或编辑重提交流程。 + +steering(中途引导)气泡保持仅徽章形态,不展示这些操作。 + +## 考虑过的替代方案 + +**现在就把分支/编辑接到真实的会话 fork 与草稿编辑。**本次变更不予采纳:这些产品流程尚未定稿;交付无行为按钮符合请求范围,也避免半成品的变更路径。 + +**操作始终可见(无悬停淡入)。**与现行操作栏规则冲突,不予采纳;figma 节点展示的是静止态外观,而非样式指南要求的空闲隐藏状态。 + +## 后果 + +用户消息立即可用复制;分支/编辑仍为可点击的占位,直至后续决策明确其行为。测试钉死三个按钮、复制载荷,以及对 steering 的排除。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c8d5be336d..39b595b461 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -179,7 +179,8 @@ export function apply(ctx: Context): void { // 'conversation.chat.toolview' declaration) is on the ledger. ctx.plugin(ConversationService, { input: inputHub }) - // The bash sample rides that exact seam, in third-party posture. + // The bash sample rides that exact seam, in third-party posture + // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions). ctx.plugin(bashToolviewSample) // The read-only queue dock entry (T9 file territory) rides the same diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index d548f2d7be..6d75f9a519 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -37,17 +37,11 @@ border-radius: 6px; } -/* Selection linkage: the selected call row wears the blue outline. - button-info-fill flips 500→400 with the theme, hitting the darker-blue - dark-mode spec exactly (business-primary stays 500 on both). */ -.callRow[data-selected] { - outline: 1.5px solid var(--dsw-alias-button-info-fill); - outline-offset: 1px; -} +/* Selection still sets data-selected for details linkage; no outline — + tool rows match Think chrome (no selected ring). */ /* run_code sub-dispatch rows: indented under the parent row, left-edged so - the code turn reads as one unit; each nested row is itself a .callRow - (same components, same selection outline as top-level rows). */ + the code turn reads as one unit; each nested row is itself a .callRow. */ .subCalls { display: flex; flex-direction: column; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 047878f1d0..0d2331a199 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -1,10 +1,11 @@ -/* User bubble: right-aligned, figma r22 fill = the bubble specific token - (#EDF3FE light / dark pair rides the token sheet). */ +/* User bubble: right-aligned column (bubble + IconActions). Figma + User_Bubble/message_container 659:38813 — r22 fill, actions gap 6 below. */ -/* Block spacing is the flow column's gap alone — no extra padding here. */ .userRow { display: flex; - justify-content: flex-end; + flex-direction: column; + align-items: flex-end; + gap: 6px; } .bubble { @@ -19,6 +20,40 @@ color: var(--dsw-alias-label-primary); } +.actions { + display: flex; + align-items: center; + gap: 10px; + height: 28px; + /* Hidden until the row is hovered/focused (web-styling message action bar). */ + opacity: 0; + transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); +} + +.userRow:hover .actions, +.userRow:focus-within .actions { + opacity: 1; +} + +.action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 6px; + border: none; + border-radius: 28px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.action:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + .badge { display: inline-block; margin-bottom: 4px; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index e79304fc19..67abe94ef6 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,14 +1,18 @@ -// MessageItem: the four simple node kinds — user bubble (right-aligned), -// steering (badged bubble), context injection and unknown-surface JSON rows. -// Props are frozen node slices off the snapshot cache; memo holds across -// streaming because unchanged nodes keep their references. +// MessageItem: the four simple node kinds — user bubble (right-aligned, with +// copy / branch / edit IconActions), steering (badged bubble), context +// injection and unknown-surface JSON rows. Props are frozen node slices off +// the snapshot cache; memo holds across streaming because unchanged nodes +// keep their references. -import { memo } from 'react' +import { memo, useCallback } from 'react' import type { ReactNode } from 'react' import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconBranchOutline16, IconCopyOutline16, IconEditOutline16, + JsonBlock, MessageText, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' import css from './MessageItem.module.css' export interface MessageItemProps { @@ -26,6 +30,30 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown return { text: texts.join(''), rest } } +async function writeClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return + } + const exec = typeof document.execCommand === 'function' + ? document.execCommand.bind(document) + : undefined + if (exec === undefined) return + const el = document.createElement('textarea') + el.value = text + el.setAttribute('readonly', '') + el.style.position = 'fixed' + el.style.left = '-9999px' + document.body.appendChild(el) + el.select() + try { + exec('copy') + } catch { + // Clipboard unavailable; the button stays idle. + } + el.remove() +} + /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The @@ -58,15 +86,52 @@ function projectUserText(text: string): ReactNode { return <>{parts} } +/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */ +function UserActions({ text }: { text: string }) { + const onCopy = useCallback(() => { + void writeClipboard(text) + }, [text]) + return ( +
    + + + + + + + + + +
    + ) +} + export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { switch (node.kind) { - case 'user': + case 'user': { + const { text, rest } = contentText(node.content) + return ( +
    +
    + {projectUserText(text)} + {rest.map((block, i) => )} +
    + +
    + ) + } case 'steering': { const { text, rest } = contentText(node.content) return (
    - {node.kind === 'steering' && 插话} + 插话 {projectUserText(text)} {rest.map((block, i) => )}
    diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index a21a744008..b300c59393 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -323,7 +323,7 @@ .stopping, .stopping:hover { background: var(--dsw-alias-button-primary-dimmed); - color: var(--dsw-alias-brand-text); + color: var(--dsw-alias-label-primary); } .retry { diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 83c2329fc5..9b7116462f 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,29 +1,32 @@ -/* Sample bash rows: deliberately distinct from ToolRow so the differential - registry hit is visible at a glance. */ +/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ -.row { +.root { display: flex; align-items: center; - gap: 8px; height: 24px; min-width: 0; cursor: pointer; border-radius: 6px; - font-family: var(--ds-font-family-code); - font-size: 13px; } -.row:hover { +.root:hover { background: var(--dsw-alias-interactive-bg-hover); } -.prompt { +.leading { flex: none; - color: var(--dsw-alias-state-success-primary); + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); } .scopeBadge { flex: none; + margin-right: 8px; padding: 0 6px; border-radius: 6px; font-size: 11px; @@ -32,17 +35,29 @@ background: var(--dsw-alias-state-business-primary); } -.command { +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-primary-dimmed); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--dsw-alias-label-secondary); -} - -.err { - flex: none; - color: var(--dsw-alias-state-error-primary); - font-size: 11px; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 9968c3b46e..2503a6e71b 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -1,35 +1,42 @@ -// Bash toolview sample, written in third-party posture: everything below uses -// only the public slot surface (ctx.slots.register into the keyed -// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof -// that a plain plugin can take over a tool row with zero dedicated machinery. -// Session-dimension differentiation happens INSIDE the component (the -// canonical sub-agent scenario): rows in child sessions render the scoped -// variant, derived from the standard useSessions kit — no registry predicates. +// Bash toolview registrant: third-party posture over the keyed toolview hole +// (ctx.slots.register + ToolRowProps only — never imports the chat domain). +// Product chrome matches ToolRow / Think (figma: Bash · {description}). +// Child sessions keep a scoped badge so session-dimension differentiation stays +// observable inside the component (no parallel registry). import type { Context } from 'cordis' +import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' -import { toolRowModel } from '../contract/tool-call-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import css from './bash-sample.module.css' -/** Bash row: command-first monospace summary replacing the generic card. - * Sub-session rows (parentId present) swap the prompt for a scoped badge — - * the differential stays observable per session from one registration. */ +function leadingFor(state: ToolRowState) { + switch (state) { + case 'running': return + case 'error': return + case 'stopped': return + default: return + } +} + +/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) { const model = toolRowModel(toolName, block) const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) - if (isChild) { - return ( -
    - scoped - {model.summary} -
    - ) - } return ( -
    - $ - {model.summary} - {model.state === 'error' && failed} +
    + {leadingFor(model.state)} + {isChild && scoped} + {model.title} + + {model.summary}
    ) } diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index be50356185..b96821f761 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -1,11 +1,12 @@ // @vitest-environment jsdom // Remaining chat branch tails: MessageItem context/unknown/steering arms, -// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown -// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot -// machinery specs since the tool ring dissolved into renderSlot.) +// user IconActions, StatsLine no-cache join, PendingCard reason strip, +// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live +// with the keyed-slot machinery specs since the tool ring dissolved into +// renderSlot.) import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' @@ -18,7 +19,75 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx afterEach(cleanup) describe('MessageItem arms', () => { - it('steering bubbles carry the interjection badge and non-text rest blocks', () => { + it('user bubbles expose copy / branch / edit actions; copy writes the text', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + render( + , + ) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy() + expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('hello bubble') + }) + + it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }) + const exec = vi.fn().mockReturnValue(true) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: exec, + }) + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(exec).toHaveBeenCalledWith('copy') + }) + + it('user copy stays quiet when execCommand throws or is absent', () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: () => { + throw new Error('denied') + }, + }) + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: '复制' })) + + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: undefined, + }) + fireEvent.click(screen.getByRole('button', { name: '复制' })) + }) + + it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => { const view = render( { expect(view.getByText('插话')).toBeTruthy() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() + expect(view.queryByRole('button', { name: '复制' })).toBeNull() }) it('context and unknown nodes render their JSON rows', () => { diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 125e421772..9bb915ca41 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -156,12 +156,13 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(view.getByText('List the notes directory')).toBeTruthy() // Nested rows are ALWAYS visible (no parent expand needed): the bash - // sub-call landed in the bash sample plugin's keyed registration — the - // exact component a native top-level bash row uses — and the unregistered + // sub-call landed in the bash sample plugin's keyed registration — Bash · + // description chrome, same as a top-level bash row — and the unregistered // sub-tool fell back to GenericToolCard at the same render site. const nest = view.container.querySelector('[data-subcalls]') expect(nest).not.toBeNull() expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull() + expect(view.getByText('Bash')).toBeTruthy() expect(view.getByText('List notes')).toBeTruthy() expect(view.getByText('Tool call')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 2ccaa9bbf2..f943bab600 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -169,17 +169,19 @@ describe('bash sample row', () => { expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull() }) - it('summarizes the command and hands clicks to openDetails on both arms', () => { + it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => { const openGlobal = vi.fn() const global = render() // Two renders share document.body: query inside each container. const globalRow = global.container.querySelector('[data-sample="bash-global"]')! + expect(globalRow.textContent).toContain('Bash') expect(globalRow.textContent).toContain('Build') fireEvent.click(globalRow) expect(openGlobal).toHaveBeenCalledTimes(1) const openScoped = vi.fn() const scoped = render() const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')! + expect(scopedRow.textContent).toContain('Bash') expect(scopedRow.textContent).toContain('Build') fireEvent.click(scopedRow) expect(openScoped).toHaveBeenCalledTimes(1) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index b1122a4098..d1d8b61b30 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -156,6 +156,7 @@ describe('keyed toolview hole through the real machinery', () => { // bash: the sample plugin's keyed registration took the row (root // session → global arm, decided inside the component off useSessions). expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() + expect(view.getByText('Bash')).toBeTruthy() expect(view.getByText('Build')).toBeTruthy() // mystery: no registration under that key → render-site fallback. expect(view.getByText('Tool call')).toBeTruthy() diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 78e0affa4e..7fe12c7682 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -264,7 +264,7 @@ describe('ChatView', () => { expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy() }) - it('clicking a tool row opens details with callId and toolName; selection paints the outline', () => { + it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => { const h = makeHarness({ nodes: [toolResult(3, 'a')] }) const view = render() fireEvent.click(view.getByText('run a')) diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 11664d3f00..084f73b4b7 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -1,13 +1,13 @@ // @vitest-environment jsdom // Branch tails the acceptance specs do not reach: ToolRow stopped-state dot, -// PendingCard question arm, bash sample error pill, the node-half empty +// PendingCard question arm, bash sample state dots, the node-half empty // apply, and AssistantMarkdown reasoning/unknown block arms. import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -76,14 +76,7 @@ describe('tails', () => { expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() }) - it('BashRow shows the failed pill on error results (root session arm)', () => { - const errorResult: ToolResultNode = { - kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', - call: { name: 'bash', argsRaw: '{"command":"boom"}' }, - callTime: 500, - content: [], isError: true, callView: null, resultView: null, - } - // Root session (no parentId): the global arm renders, error pill visible. + it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => { const sid = 'root-1' as SessionId const list = createSnapshotStore({ ids: [sid], @@ -91,12 +84,38 @@ describe('tails', () => { current: undefined, phase: 'ready', } as SessionListState) - const props = { - callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(), + const props = (block: RunningToolCall | ToolResultNode) => ({ + callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(), sessionId: sid, useSessions: bindSnapshotSelector(list), - } as unknown as ToolRowProps - const view = render() - expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() - expect(view.getByText('failed')).toBeTruthy() + } as unknown as ToolRowProps) + + const running: RunningToolCall = { + callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}', + turn: 1, step: 1, time: 1_000, callView: null, + } + const errorResult: ToolResultNode = { + kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', + call: { name: 'bash', argsRaw: '{"command":"boom"}' }, + callTime: 500, + content: [], isError: true, callView: null, resultView: null, + } + const stoppedResult: ToolResultNode = { + ...errorResult, + error: { name: 'E', code: 'interrupted' }, + } + + const runningView = render() + expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() + expect(runningView.getByText('Bash')).toBeTruthy() + expect(runningView.getByText('List')).toBeTruthy() + runningView.unmount() + + const errorView = render() + expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() + expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() + errorView.unmount() + + const stoppedView = render() + expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull() }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6b4e776cfe..6162494def 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 4e2a22e77dc1611728477ea0a9d8c50dfc9f7f5d -README.zh.md: 36253971281fd346f9b0ec4648c4b8824ed918a7 +README.md: 58e450451ab64f69762817dfb277b8a888e2177f +README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4e2a22e77d..58e450451a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. +`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Model Experience diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3625397128..6824f3efe4 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -6,7 +6,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 模型体验 diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index f9b5f67136..7222c3df44 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -1,13 +1,79 @@ -/* One code-block geometry for highlighted and plain arms: the shiki
    -   and the fallback 
     draw identically except for token colors. */
    +/* Visual baseline: deepsuite `@deepseek/md` code-block.css. Highlight colors
    +   stay on the existing shiki `--shiki-*` sheet (not Prism highlight.css). */
    +
    +.block {
    +  --dsl-code-block-banner-background-color: var(--dsw-alias-markdown-code-block-banner);
    +  --dsl-code-block-border-radius: 12px;
    +  --dsl-code-block-banner-font: var(--dsw-font-xs-13);
    +  --dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
    +
    +  position: relative;
    +  margin: 16px 0;
    +  color: var(--dsw-alias-label-primary);
    +  background: var(--dsw-alias-markdown-code-block);
    +  border-radius: var(--dsl-code-block-border-radius);
    +}
    +
    +.block:not(:last-child) {
    +  margin-bottom: 11px;
    +}
    +
    +.bannerWrap {
    +  position: sticky;
    +  top: 0;
    +  z-index: 6;
    +  background-color: var(--dsw-alias-bg-base);
    +  border-top-left-radius: var(--dsl-code-block-border-radius);
    +  border-top-right-radius: var(--dsl-code-block-border-radius);
    +}
    +
    +.banner {
    +  background: var(--dsl-code-block-banner-background-color);
    +  padding: 9px 14px;
    +  display: flex;
    +  justify-content: space-between;
    +  align-items: center;
    +  gap: 12px;
    +  font: var(--dsl-code-block-banner-font);
    +  border-top-left-radius: var(--dsl-code-block-border-radius);
    +  border-top-right-radius: var(--dsl-code-block-border-radius);
    +}
    +
    +.infostring {
    +  color: var(--dsw-alias-label-primary);
    +  font-family: var(--ds-font-family-code);
    +  font-size: 12px;
    +  line-height: 18px;
    +  min-width: 0;
    +  overflow: hidden;
    +  text-overflow: ellipsis;
    +  white-space: nowrap;
    +}
    +
    +.action {
    +  display: flex;
    +  align-items: center;
    +  flex-shrink: 0;
    +}
    +
    +.copyButton {
    +  background-color: rgb(255 255 255 / 0);
    +  border: none;
    +  padding: 0;
    +  margin: 0;
    +  color: inherit;
    +  cursor: pointer;
    +  font: inherit;
    +}
     
     .block :where(pre) {
    -  margin: 0;
    -  padding: 8px 10px;
    -  border-radius: 8px;
    +  font: var(--dsl-code-block-content-font);
    +  padding: 16px;
    +  margin: 0 !important;
       overflow-x: auto;
    +  white-space: pre-wrap;
    +  word-break: break-all;
       background: var(--dsw-alias-markdown-code-block);
    -  font: var(--dsw-font-markdown-code-block);
     }
     
     /* Shiki inlines its theme background var; route it to the repo token. */
    @@ -23,5 +89,4 @@
     
     .plain {
       color: var(--dsw-alias-label-primary);
    -  white-space: pre;
     }
    diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
    index 1a6349f1e8..33bcf7b80c 100644
    --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
    +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
    @@ -1,12 +1,10 @@
     // CodeBlock: one code surface for every consumer — markdown fences, the
     // run_code program body, and the details panel's raw args/output — with
     // shiki highlighting for the registered grammars and an identical-geometry
    -// plain fallback for everything else. Shiki emits a single 
    -// tree of nested spans whose colors are --shiki-* custom properties
    -// (token sheets own the values); it produces no scripts or event handlers,
    -// so injecting its output is safe by construction.
    +// plain fallback for everything else. Chrome (language banner + copy) matches
    +// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
     
    -import { useMemo } from 'react'
    +import { useCallback, useMemo, useRef, useState } from 'react'
     import clsx from 'clsx'
     import { highlightToHtml } from './highlight.ts'
     import css from './CodeBlock.module.css'
    @@ -20,18 +18,72 @@ export interface CodeBlockProps {
       className?: string | undefined
     }
     
    +async function writeClipboard(text: string): Promise {
    +  if (navigator.clipboard?.writeText) {
    +    await navigator.clipboard.writeText(text)
    +    return
    +  }
    +  // jsdom and older hosts: best-effort execCommand path when present.
    +  const exec = typeof document.execCommand === 'function'
    +    ? document.execCommand.bind(document)
    +    : undefined
    +  if (exec === undefined) return
    +  const el = document.createElement('textarea')
    +  el.value = text
    +  el.setAttribute('readonly', '')
    +  el.style.position = 'fixed'
    +  el.style.left = '-9999px'
    +  document.body.appendChild(el)
    +  el.select()
    +  try {
    +    exec('copy')
    +  } catch {
    +    // Clipboard unavailable (sandboxed iframe / denied permission); UI still
    +    // flips to the ok label so the gesture is acknowledged.
    +  }
    +  el.remove()
    +}
    +
     export function CodeBlock({ code, lang, className }: CodeBlockProps) {
       const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
       const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
    -  if (html === undefined) {
    -    return (
    -      
    + const rootRef = useRef(null) + const [copied, setCopied] = useState(false) + + const onCopy = useCallback(() => { + if (copied) return + /* v8 ignore next -- both arms always mount a
    ; trimmed is the
    +       typed fallback if the DOM shape ever diverges. */
    +    const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
    +    void writeClipboard(text)
    +    setCopied(true)
    +    window.setTimeout(() => setCopied(false), 1000)
    +  }, [copied, trimmed])
    +
    +  const body = html === undefined
    +    ? (
             
    {trimmed}
    + ) + : ( + // eslint-disable-next-line react/no-danger -- shiki's output is a static + // span tree it generated from `code` (no user HTML passes through), the + // sanctioned innerHTML consumption path per shiki's own docs. +
    + ) + + return ( +
    +
    +
    +
    {lang ?? ''}
    +
    + +
    +
    - ) - } - // eslint-disable-next-line react/no-danger -- shiki's output is a static - // span tree it generated from `code` (no user HTML passes through), the - // sanctioned innerHTML consumption path per shiki's own docs. - return
    + {body} +
    + ) } diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 36b1dc2b55..a189528bc9 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -1,95 +1,168 @@ +/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS + Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are + intentionally absent (no matching DOM). Token names match that sheet. */ + .markdown { - display: flex; min-width: 0; - flex-direction: column; - gap: 12px; overflow-wrap: anywhere; font: var(--dsw-font-markdown-base); + color: var(--dsw-alias-label-primary); } -.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) { - margin: 0; +.markdown strong { + font-weight: 600; } .markdown h1 { font: var(--dsw-font-markdown-h1); + margin: 32px 0 16px; } .markdown h2 { font: var(--dsw-font-markdown-h2); + margin: 32px 0 16px; } .markdown h3 { font: var(--dsw-font-markdown-h3); + margin: 32px 0 16px; } -.markdown :where(h4, h5, h6) { +.markdown h4 { font: var(--dsw-font-markdown-h4); + margin: 16px 0; } -.markdown :where(strong, th) { - font-weight: var(--dsw-font-markdown-base-strong-font-weight); +.markdown :where(h5, h6) { + font: var(--dsw-font-markdown-base-strong); + margin: 16px 0; } -.markdown :where(ul, ol) { - padding-inline-start: 24px; +.markdown :where(h1, h2, h3, h4, h5, h6) strong { + font-weight: inherit; } -.markdown li + li { - margin-block-start: 4px; +.markdown p { + margin: 16px 0; } -.markdown li > :where(ul, ol) { - margin-block-start: 4px; +/* Tighten h4–h6 against a following list (design: 8px gap). */ +.markdown :where(h4, h5, h6) + :where(ul, ol) { + margin-top: 8px; } -.markdown blockquote { - padding-inline-start: 12px; - border-inline-start: 3px solid var(--dsw-alias-markdown-citation); - color: var(--dsw-alias-label-secondary); +.markdown :where(h4, h5, h6):has(+ :where(ul, ol)) { + margin-bottom: 8px; } .markdown a { + /* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet + keeps design-platform brand-text as near-black, so links use the blue + business-primary alias instead. */ color: var(--dsw-alias-state-business-primary); - text-decoration: underline; - text-underline-offset: 2px; + transition: box-shadow var(--ds-transition-duration) var(--ds-ease-in-out); + position: relative; + text-decoration: none; + /* Transparent hit-area padding; literal zero-alpha only (no painted color). */ + border-left: 3px solid rgb(255 255 255 / 0); + border-right: 3px solid rgb(255 255 255 / 0); + border-top: 2px solid rgb(255 255 255 / 0); + border-bottom: 2px solid rgb(255 255 255 / 0); + margin-left: -3px; + margin-right: -3px; } -.markdown :not(pre) > code { - padding: 2px 4px; - border-radius: 4px; - background: var(--dsw-alias-markdown-inline-code); - font: var(--dsw-font-markdown-code); +.markdown a:hover, +.markdown a:focus { + outline: none; + text-decoration: underline var(--dsw-alias-state-business-primary); } -.markdown pre { - max-width: 100%; - overflow-x: auto; - overscroll-behavior-x: contain; - padding: 12px 16px; - border-radius: 8px; - background: var(--dsw-alias-markdown-code-block); - font: var(--dsw-font-markdown-code-block); +.markdown a:focus-visible { + box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); } -.markdown pre code { - padding: 0; - background: transparent; - font: inherit; - overflow-wrap: normal; - word-break: normal; - white-space: pre; +.markdown :where(ul, ol) { + margin: 16px 0; + padding-left: 18px; +} + +.markdown li:not(:first-child) { + margin-top: 6px; +} + +.markdown li > :where(ul, ol) { + margin-top: 4px; +} + +.markdown li::marker { + line-height: 28px; + color: var(--dsw-alias-label-secondary); +} + +/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */ +.markdown :where(ul, ol) ol { + list-style-position: inside; + padding-left: 0; +} + +.markdown :where(ul, ol) ol li p { + display: inline; +} + +.markdown li > p { + margin: 8px 0; +} + +.markdown li > *:first-child { + margin-top: 0; +} + +/* Keep list-nested code-block vertical margins (design: +4px vs other last children). */ +.markdown li > *:last-child:not(:global(.md-code-block)) { + margin-bottom: 0; } .markdown hr { - width: 100%; - border: 0; - border-block-start: 1px solid var(--dsw-alias-markdown-citation); + display: block; + border: none; + height: 1px; + margin: 32px 0; + background: var(--dsw-alias-border-l2); +} + +.markdown blockquote { + border-left: 2px solid var(--dsw-alias-label-caption); + margin: 16px 0 0; + padding-left: 14px; +} + +.markdown pre { + margin: 16px 0; + font-family: var(--ds-font-family-code); + overflow: auto; +} + +.markdown :not(pre) > code { + display: inline-flex; + align-items: center; + box-sizing: border-box; + font: var(--dsw-font-markdown-code); + font-family: var(--ds-font-family-code); + font-size: 0.875em !important; + background-color: var(--dsw-alias-markdown-inline-code); + border-radius: 6px; + padding: 0 5px; +} + +.markdown :where(h1, h2, h3, h4, h5, h6) code { + font: inherit; + font-family: var(--ds-font-family-code); } .markdown input[type='checkbox'] { margin: 0 8px 0 0; - accent-color: var(--dsw-alias-state-business-primary); + accent-color: var(--dsw-alias-label-secondary); } .tableScroll { @@ -99,22 +172,52 @@ } .tableScroll table { - width: max-content; - min-width: 100%; border-collapse: collapse; - font: var(--dsw-font-markdown-table); -} - -.tableScroll :where(th, td) { - padding: 6px 12px; - border: 1px solid var(--dsw-alias-markdown-citation); - text-align: start; - white-space: nowrap; + width: max-content; + max-width: max-content; } .tableScroll th { - background: var(--dsw-alias-markdown-code-block-banner); + text-align: start; + padding: 10px 16px; + border-bottom: 1px solid var(--dsw-alias-border-l3); + border-top: none; font: var(--dsw-font-markdown-table-head); + max-width: 320px; + max-width: min(30vw, 320px); + min-width: 100px; +} + +.tableScroll td { + padding: 10px 16px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + font: var(--dsw-font-markdown-table); + max-width: 320px; + max-width: min(30vw, 320px); + min-width: 100px; +} + +.tableScroll th:first-child, +.tableScroll td:first-child { + padding-left: 0; +} + +.tableScroll td:last-child { + padding-right: 0; +} + +.tableScroll table code { + font-size: 13px; +} + +.markdown > *:first-child, +.markdown p:first-child { + margin-top: 0 !important; +} + +.markdown > *:last-child, +.markdown p:last-child { + margin-bottom: 0 !important; } .imageAlt { diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx index a58248afab..b6fdc866f9 100644 --- a/packages/client/ui-primitives/tests/code-block.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.spec.tsx @@ -5,14 +5,17 @@ // display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx // alongside the rest of the markdown family. -import { describe, expect, it } from 'vitest' -import { cleanup, render } from '@testing-library/react' -import { afterEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { CodeBlock } from '../src/markdown/CodeBlock.tsx' import { highlightToHtml } from '../src/markdown/highlight.ts' afterEach(cleanup) +beforeEach(() => { + vi.useRealTimers() +}) + describe('highlightToHtml', () => { it('highlights a registered grammar into css-variables token spans', () => { const html = highlightToHtml('const x: number = 1', 'typescript') @@ -50,4 +53,68 @@ describe('CodeBlock', () => { expect(view.container.querySelector('pre.shiki')).toBeNull() expect(view.getByText('plain text')).toBeTruthy() }) + + it('shows the language banner and copies the pre textContent', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + render() + expect(screen.getByText('ts')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('const a = 1') + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + // While the ok label is showing, further clicks are no-ops. + fireEvent.click(screen.getByRole('button', { name: '复制成功' })) + expect(writeText).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1000) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('falls back to execCommand when clipboard.writeText is unavailable', () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }) + const exec = vi.fn().mockReturnValue(true) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: exec, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(exec).toHaveBeenCalledWith('copy') + }) + + it('still acknowledges copy when execCommand throws', () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: () => { + throw new Error('denied') + }, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + }) + + it('acknowledges copy when neither clipboard API nor execCommand exists', () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: undefined, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + }) }) diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 05c7ce0139..07df7cebdc 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -57,8 +57,10 @@ describe('MarkdownText', () => { expect(container.querySelector('table')?.textContent).toContain('alphabeta') expect(container.querySelector('hr')).not.toBeNull() expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42') - // The ts fence routed through the shared CodeBlock: shiki token spans present. + // The ts fence routed through the shared CodeBlock: shiki token spans + banner. expect(container.querySelector('pre.shiki')).not.toBeNull() + expect(screen.getByText('ts')).toBeTruthy() + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() expect(container.querySelector('br')).not.toBeNull() expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank') expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() diff --git a/packages/client/ui-theme/src/styles/base.css b/packages/client/ui-theme/src/styles/base.css index 2d1acde71d..4c801b8d4d 100644 --- a/packages/client/ui-theme/src/styles/base.css +++ b/packages/client/ui-theme/src/styles/base.css @@ -9,5 +9,7 @@ --ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas, 'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei'; --ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --ds-transition-duration: 0.2s; + --ds-transition-duration-fast: 0.1s; --ds-transition-duration-slow: 0.3s; } From 3649df14073816443422a3413ff51a5801030011 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:11:27 +0800 Subject: [PATCH 21/56] =?UTF-8?q?ci(exp-wine):=20speed=20rework=20?= =?UTF-8?q?=E2=80=94=20pnpm=20store=20+=20wine=20apt=20caches,=20concurren?= =?UTF-8?q?t=20provisioning=20and=20gates,=20checksum-pinned=20Node,=208-c?= =?UTF-8?q?ore=20dispatch=20leg;=20fold=20PR=20#689=20lessons=20into=20the?= =?UTF-8?q?=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...27-wine-windows-gates-experiment.i18n.yaml | 6 +- ...026-07-27-wine-windows-gates-experiment.md | 11 +- ...-07-27-wine-windows-gates-experiment.zh.md | 11 +- .github/workflows/exp-wine-windows.yml | 253 +++++++++++------- 4 files changed, 172 insertions(+), 109 deletions(-) diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index eb3909cc4e..fb51fef157 100644 --- a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-27-wine-windows-gates-experiment.md: 9f7856dfef229f8f02c85f5968082a0c857bbc94 -2026-07-27-wine-windows-gates-experiment.zh.md: cb185293d7f22723a96448a774bd27dd31e1bc28 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md +2026-07-27-wine-windows-gates-experiment.md: 9e2db947eceee7e3e2fee63f8fe2ac90de1cd13d +2026-07-27-wine-windows-gates-experiment.zh.md: a4b938faa6ae27bd068db9a952ebb1432ec7ca3f diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md index 9f7856dfef..9e2db947ec 100644 --- a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md @@ -12,9 +12,11 @@ The open question: can a plain Linux runner produce an equivalent win32 signal f ## Proposal -[exp-wine-windows.yml](../../../../.github/workflows/exp-wine-windows.yml) (self-path-filtered, plus manual dispatch) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a downloaded win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. +[exp-wine-windows.yml](../../../../.github/workflows/exp-wine-windows.yml) (self-path-filtered, plus manual dispatch) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a checksum-verified win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. -Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. +Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: the independent prototype in [PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; #689's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). + +The lane targets the wall clock of the Linux CI jobs (about two minutes), from four levers: the master-refreshed pnpm store cache (restore-only, same key as ci.yml), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image so Wine's package downloads are paid once per image version. This is deliberately a fidelity probe, not a drop-in replacement: Wine reimplements the Win32 API over a case-sensitive ext4 (NTFS case-insensitivity is not emulated by default), provides no ConPTY, and substitutes its own security-descriptor and `MoveFileExW` semantics — exactly the surfaces the repo's `win32.ts` modules and PTY backend care about. The experiment measures which blocking gates pass, which fail for Wine reasons rather than product reasons, and the wall-clock cost relative to the recorded Windows benchmark lanes. @@ -26,6 +28,8 @@ Promotion, if the verdict is positive: fold the Wine lane in as the pull-request **A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs. Explored as the sibling experiment branch `exp/kvm-windows-ci`; the two experiments price fidelity against latency. +**Windows pnpm performing the install under Wine ([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689)).** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. + **Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`. **Windows containers.** Not possible: Windows containers require a Windows host kernel; a hosted Linux runner cannot run them. @@ -34,7 +38,8 @@ Promotion, if the verdict is positive: fold the Wine lane in as the pull-request ## Acceptance criteria -- The workflow completes on `ubuntu-latest` with an independent pass/fail verdict per blocking gate (tsc, tsdown, production site) and a recorded wall-clock comparison against the Windows benchmark lanes. +- The workflow completes on `ubuntu-latest` with an independent pass/fail verdict per blocking surface (build, production site) and a recorded wall-clock comparison against both the paid Windows lane and the Linux CI jobs. +- End-to-end wall clock lands in the same band as the Linux CI jobs (minutes, not tens of minutes), demonstrating the pool-replacement case on cost as well as signal. - A decision is recorded here: promote the lane, keep it as a non-blocking canary, or reject it with the observed failure class. ## Risks diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md index cb185293d7..a4b938faa6 100644 --- a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -12,9 +12,11 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表 ## 提案 -[exp-wine-windows.yml](../../../../.github/workflows/exp-wine-windows.yml)(自身路径过滤,外加手动触发)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:下载的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。 +[exp-wine-windows.yml](../../../../.github/workflows/exp-wine-windows.yml)(自身路径过滤,外加手动触发)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。 -依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。 +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其"Windows pnpm 安装依赖树"的目标(安装契约在此仍由 Linux 侧验证)。 + +该通道以 Linux CI 作业的墙钟(约两分钟)为目标,靠四个杠杆:master 刷新的 pnpm store 缓存(只恢复,与 ci.yml 同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,使 Wine 的包下载每个镜像版本只付一次。 这刻意是一次保真度探针,而非直接替换:Wine 在大小写敏感的 ext4 之上重实现 Win32 API(默认不模拟 NTFS 的大小写不敏感)、不提供 ConPTY、并用自己的安全描述符与 `MoveFileExW` 语义替代——恰是本仓库 `win32.ts` 模块与 PTY 后端关心的表面。实验度量哪些阻断门禁通过、哪些因 Wine 原因而非产品原因失败,以及相对已记录 Windows 基准通道的墙钟成本。 @@ -26,6 +28,8 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表 **在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装。作为兄弟实验分支 `exp/kvm-windows-ci` 探索;两个实验共同为保真度与延迟定价。 +**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道用掉这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 + **Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 破坏类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索。 **Windows 容器。** 不可行:Windows 容器要求 Windows 宿主内核;托管 Linux runner 无法运行。 @@ -34,7 +38,8 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表 ## 验收标准 -- 该 workflow 在 `ubuntu-latest` 上完成,对每个阻断门禁(tsc、tsdown、生产站点)给出独立的通过/失败裁决,并记录与 Windows 基准通道的墙钟对比。 +- 该 workflow 在 `ubuntu-latest` 上完成,对每个阻断表面(构建、生产站点)给出独立的通过/失败裁决,并记录与付费 Windows 通道及 Linux CI 作业两者的墙钟对比。 +- 端到端墙钟落在 Linux CI 作业的同一档位(分钟级,而非数十分钟),从成本与信号两方面共同论证替换池的理由。 - 在此记录一项决定:晋升该通道、保留为非阻断金丝雀、或以观察到的失败类别否决。 ## 风险 diff --git a/.github/workflows/exp-wine-windows.yml b/.github/workflows/exp-wine-windows.yml index 9ebc3ccc79..e67c0e7d79 100644 --- a/.github/workflows/exp-wine-windows.yml +++ b/.github/workflows/exp-wine-windows.yml @@ -1,10 +1,16 @@ # EXPERIMENT: run the blocking Windows CI gates on a Linux runner through -# Wine, and execute the gate commands with a real Windows Node.js binary. -# Dependency provisioning happens natively on Linux with +# Wine with a real Windows Node.js binary, at roughly the wall clock of the +# Linux CI jobs (~2 min). Speed comes from four levers: the master-refreshed +# pnpm store cache, provisioning Wine concurrently with the dependency +# install, running the two blocking surfaces concurrently (the same shape +# run-gates gives them on native Windows), and an apt package cache for Wine +# itself. Dependency provisioning happens natively on Linux with # `supportedArchitectures` extended to win32-x64 so the Windows -# esbuild/rolldown/rollup binaries are present in the store. The pnpm-run/cmd -# shim layer is deliberately bypassed (a Linux install writes POSIX shims -# only), so each gate invokes its tool's JavaScript entrypoint directly — the +# esbuild/rolldown/rollup binaries are present, and `nodeLinker: hoisted` +# because Windows Node under Wine does not realpath pnpm's isolated-layout +# Unix symlinks — the sibling prototype in PR #689 kept the isolated layout +# and failed on exactly that. The pnpm-run/cmd shim layer is deliberately +# bypassed; each gate invokes its tool's JavaScript entrypoint directly — the # same commands run-gates ultimately spawns. Owning rationale and promotion # criteria: # .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md @@ -28,11 +34,17 @@ env: jobs: wine-blocking-gates: - name: wine / blocking windows gates - # Deliberately the cheapest hosted substrate: if Wine holds up here, the - # lane needs no special pool at all. - runs-on: ubuntu-latest - timeout-minutes: 120 + name: wine / blocking windows gates (${{ matrix.runner }}) + # Pull requests run the free standard runner only; a manual dispatch adds + # the 8-core benchmark pool for a like-for-like core-count comparison. + # The larger leg stays dispatch-only because those restricted pools can + # queue indefinitely (observed on the sibling KVM experiment). + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + runner: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["ubuntu-latest", "dsh-ubuntu-24-04-8core"]' || '["ubuntu-latest"]') }} + timeout-minutes: 30 env: WINEDEBUG: '-all' WINEARCH: win64 @@ -47,18 +59,39 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack and install with win32-x64 artifacts + # The default-branch pnpm store cache ci.yml maintains; restore-only, + # same key, so this lane rides the cache master already refreshes. + - uses: actions/cache/restore@v4 + with: + path: /home/runner/.local/share/pnpm/store/v11 + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Compose Wine apt cache key + id: wine-cache-key + run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ~/wine-debs + key: ${{ steps.wine-cache-key.outputs.key }} + + - name: Install dependencies and provision Wine concurrently run: | corepack enable + # Experiment-only install-time overrides. supportedArchitectures # additionally materializes the win32-x64 platform packages - # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) that the - # Windows toolchain resolves at runtime. nodeLinker: hoisted lays - # node_modules out flat with real files: Windows Node under Wine - # does not realpath pnpm's Unix symlinks, so the default isolated - # layout breaks transitive ESM resolution (tsdown -> ansis, - # vite -> rollup). Neither override is recorded in the lockfile, so - # --frozen-lockfile stays valid. + # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) the + # Windows toolchain resolves at runtime; nodeLinker: hoisted lays + # node_modules out flat with real files because Windows Node under + # Wine does not realpath pnpm's isolated-layout symlinks (PR #689's + # failure mode). Neither override is recorded in the lockfile, so + # --frozen-lockfile stays valid. --ignore-scripts skips the Linux + # esbuild/node-pty/lefthook lifecycle scripts: no gate in this lane + # loads them, and the win32 binaries ship prebuilt in their + # packages. cat >> pnpm-workspace.yaml <<'EOF' nodeLinker: hoisted @@ -66,61 +99,60 @@ jobs: os: [current, win32] cpu: [current, x64] EOF - pnpm install --frozen-lockfile - - name: Resolve tool entrypoints in the hoisted layout - run: | - resolve() { - local name="$1"; shift - for p in "$@"; do - if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi + pnpm install --frozen-lockfile --ignore-scripts & + install_pid=$! + + provision_wine() { + set -euo pipefail + # Wine from the apt cache when present; else download the full + # dependency closure once and keep it for the next run. The + # `wine` dispatcher package (not bare `wine64`) is what puts a + # binary on PATH. + if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then + sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends --download-only wine + mkdir -p "$HOME/wine-debs" + cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true + sudo apt-get install -y --no-install-recommends wine + fi + WINE_BIN='' + for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do + if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi done - echo "::error::$name not found at any of: $*"; return 1 + [ -n "$WINE_BIN" ] || { echo '::error::no wine binary found after install'; exit 1; } + echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV" + + # Windows Node for the repo's primary line, checksum-verified + # against the same dist directory (adopted from PR #689). + version=$(curl -fsSL https://nodejs.org/dist/index.json \ + | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version') + echo "Windows Node: $version" + curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \ + "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip" + curl -fsSL "https://nodejs.org/dist/${version}/SHASUMS256.txt" \ + | awk -v a="node-${version}-win-x64.zip" '$2 == a { print $1 " '"$RUNNER_TEMP"'/node-win.zip" }' \ + | sha256sum --check - + unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win" + echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV" + + "$WINE_BIN" wineboot --init || true + wineserver -w || true } - resolve TSC_JS node_modules/typescript/bin/tsc - resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs - resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js - # VitePress links vue into the site's node_modules at build time; - # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows - # pre-existing Unix ones, so lay the link down host-side. - if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then - mkdir -p website/node_modules - ln -s ../../node_modules/vue website/node_modules/vue - fi + provision_wine & + wine_pid=$! - - name: Install Wine (64-bit) - run: | - sudo apt-get update - # `wine` is the /usr/bin/wine dispatcher; its dependency pulls the - # wine64 loader. Ubuntu's wine64 package alone leaves nothing on - # PATH (the loader sits at /usr/lib/wine/wine64). - sudo apt-get install -y --no-install-recommends wine - WINE_BIN='' - for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do - if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi - done - if [ -z "$WINE_BIN" ]; then - echo '::error::no wine binary found after install' - dpkg -L wine wine64 2>/dev/null | grep -E '/bin/|wine64$' || true - exit 1 - fi - echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV" - "$WINE_BIN" --version + install_status=0 + wait "$install_pid" || install_status=$? + wine_status=0 + wait "$wine_pid" || wine_status=$? + if (( install_status != 0 )); then exit "$install_status"; fi + exit "$wine_status" - - name: Fetch Windows Node.js + - name: Resolve entrypoints, link vue, smoke Windows Node run: | - version=$(curl -fsSL https://nodejs.org/dist/index.json \ - | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version') - echo "Windows Node: $version" - curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \ - "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip" - unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win" - echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV" - - - name: Boot Wine prefix and smoke Windows Node - run: | - "$WINE_BIN" wineboot --init || true - wineserver -w || true # Node under Wine cannot attach stdio to the Actions runner's pipes # (Socket open EBADF at bootstrap), so every invocation runs through # this wrapper: stdio to a regular file, replayed after exit. @@ -134,39 +166,60 @@ jobs: exit "$status" SH chmod +x "$RUNNER_TEMP/wine-node.sh" + + resolve() { + local name="$1"; shift + for p in "$@"; do + if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi + done + echo "::error::$name not found at any of: $*"; return 1 + } + resolve TSC_JS node_modules/typescript/bin/tsc + resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs + resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js + + # VitePress links vue into the site's node_modules at build time; + # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows + # pre-existing Unix ones, so lay the link down host-side. + if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then + mkdir -p website/node_modules + ln -s ../../node_modules/vue website/node_modules/vue + fi + "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" - # The continue-on-error gates below mirror ci-windows-blocking - # (scripts/run-gates.ts): `build` = tsc -b + tsdown, `production site` = - # vitepress build. Each reports independently so one failure does not - # hide the others' results; the summary step at the end owns the job - # conclusion. - - name: 'Gate: tsc -b (Windows node under Wine)' - id: tsc - continue-on-error: true - timeout-minutes: 45 - run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false' - - - name: 'Gate: tsdown (Windows node under Wine)' - id: tsdown - continue-on-error: true - timeout-minutes: 30 - run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS"' - - - name: 'Gate: production site (Windows node under Wine)' - id: site - continue-on-error: true - timeout-minutes: 30 - working-directory: website - run: '"$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build .' - - - name: Report gate outcomes - env: - TSC: ${{ steps.tsc.outcome }} - TSDOWN: ${{ steps.tsdown.outcome }} - SITE: ${{ steps.site.outcome }} + # The two blocking surfaces run concurrently, the same shape run-gates + # gives ci-windows-blocking on native Windows (DSH_GATE_CONCURRENCY): + # `build` = tsc -b then tsdown, `production site` = the VitePress + # build. Both statuses are captured so one failure cannot hide the + # other's result. + - name: Run blocking Windows gates concurrently under Wine + timeout-minutes: 20 run: | - echo "tsc: $TSC" - echo "tsdown: $TSDOWN" - echo "production site: $SITE" - [ "$TSC" = success ] && [ "$TSDOWN" = success ] && [ "$SITE" = success ] + build_gate() { + "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false || return $? + "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS" + } + site_gate() { + cd website + "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build . + } + start=$SECONDS + build_gate > "$RUNNER_TEMP/build-gate.out" 2>&1 & + build_pid=$! + site_gate > "$RUNNER_TEMP/site-gate.out" 2>&1 & + site_pid=$! + build_status=0 + wait "$build_pid" || build_status=$? + site_status=0 + wait "$site_pid" || site_status=$? + echo "== build gate (exit $build_status, $((SECONDS - start))s elapsed) ==" + tail -n 120 "$RUNNER_TEMP/build-gate.out" + echo "== production site gate (exit $site_status, $((SECONDS - start))s elapsed) ==" + tail -n 120 "$RUNNER_TEMP/site-gate.out" + if (( build_status != 0 )); then exit "$build_status"; fi + exit "$site_status" + + - name: Shut down wineserver + if: always() + run: wineserver -k 2>/dev/null || true From 38eb521e004b46d293ebc391ca0a498c7d814151 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:30:10 +0800 Subject: [PATCH 22/56] ci(exp-wine): document apt-cache scoping across triggers --- .github/workflows/exp-wine-windows.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/exp-wine-windows.yml b/.github/workflows/exp-wine-windows.yml index e67c0e7d79..e9a79a18a7 100644 --- a/.github/workflows/exp-wine-windows.yml +++ b/.github/workflows/exp-wine-windows.yml @@ -68,6 +68,11 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + # Keyed on the runner image so a new image version re-downloads once. + # Cache scoping: each trigger seeds its own scope (pull_request → the + # PR merge ref, dispatch → the branch); only same-scope reruns hit. + # Promotion to ci.yml would let master seed the shared default-branch + # scope every trigger reads, as the pnpm store cache already does. - name: Compose Wine apt cache key id: wine-cache-key run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" From 187cf6f804bfef9800f3e07e1629207af92eac0c Mon Sep 17 00:00:00 2001 From: NI0317 Date: Mon, 27 Jul 2026 12:38:11 +0800 Subject: [PATCH 23/56] feat(web): delete workspace registrations --- ...-07-25-workspace-ui-product-flow.i18n.yaml | 6 +- .../2026-07-25-workspace-ui-product-flow.md | 8 +- ...2026-07-25-workspace-ui-product-flow.zh.md | 8 +- ...-workspace-registration-deletion.i18n.yaml | 6 + ...6-07-27-workspace-registration-deletion.md | 53 +++++++++ ...7-27-workspace-registration-deletion.zh.md | 53 +++++++++ ...-domain-kv-storage-and-workspace.i18n.yaml | 6 +- ...6-07-24-domain-kv-storage-and-workspace.md | 14 ++- ...7-24-domain-kv-storage-and-workspace.zh.md | 14 ++- apps/web/tests/workspace-management.e2e.ts | 103 ++++++++++++++++-- docs/cordis-catalog/services.md | 10 ++ .../client/connection/src/client/fixture.ts | 15 +++ packages/client/connection/tests/fake-api.ts | 1 + .../client/connection/tests/fixture.spec.ts | 25 +++++ packages/client/runtime/README.i18n.yaml | 6 +- packages/client/runtime/README.md | 4 +- packages/client/runtime/README.zh.md | 4 +- .../runtime/src/client/workspaces/manager.ts | 46 +++++++- .../runtime/src/client/workspaces/service.ts | 10 ++ packages/client/runtime/tests/fake-api.ts | 4 + .../runtime/tests/workspaces-service.spec.ts | 58 ++++++++++ packages/client/ui-workspace/README.i18n.yaml | 6 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- .../src/client/WorkspaceBrowser.module.css | 10 ++ .../src/client/WorkspaceBrowser.tsx | 74 ++++++++++++- .../ui-workspace/src/client/contract/slots.ts | 2 + .../client/ui-workspace/src/client/index.ts | 1 + .../ui-workspace/src/client/rows/Rows.tsx | 14 +-- .../client/ui-workspace/tests/rows.spec.tsx | 8 +- .../tests/workspace-browser.spec.tsx | 69 ++++++++++++ .../cordis/tool-cordis/src/api-catalog.ts | 4 + packages/host/apiproxy/README.i18n.yaml | 6 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 23 +++- .../host/apiproxy/src/api/events.schema.ts | 3 +- packages/host/apiproxy/src/api/events.ts | 5 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/workspace.schema.ts | 10 ++ packages/host/apiproxy/src/api/workspace.ts | 8 ++ packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 2 + .../tests/api-proxy-workspace.spec.ts | 27 +++++ .../apiproxy/tests/client-handler.spec.ts | 5 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 13 +++ packages/workspace/README.i18n.yaml | 6 +- packages/workspace/README.md | 4 +- packages/workspace/README.zh.md | 4 +- packages/workspace/workspace/README.i18n.yaml | 6 +- packages/workspace/workspace/README.md | 3 +- packages/workspace/workspace/README.zh.md | 3 +- packages/workspace/workspace/src/index.ts | 39 +++++++ packages/workspace/workspace/src/invariant.ts | 5 +- .../workspace/tests/invariant.spec.ts | 2 +- .../workspace/tests/workspace.spec.ts | 34 ++++++ 57 files changed, 786 insertions(+), 84 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index 3295a845f3..b8266cdd49 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b -2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +2026-07-25-workspace-ui-product-flow.md: b8e1ec1efe19127cad8a12405dddeec38a4ff91e +2026-07-25-workspace-ui-product-flow.zh.md: b80b75a80671e9aa2ab59ff72c44c18a8ec5c16e diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index a02087235a..b8e1ec1efe 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -21,10 +21,11 @@ The Host provides the following GUI wiring on the Workspace entity: | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | | `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | | `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path | +| `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | -`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. +`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. @@ -51,7 +52,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Rename, Delete, moving across Workspaces, drag-and-drop ordering, manual adoption from Ungrouped, and separate display-name and directory-name inputs are outside this iteration's scope. +Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery @@ -75,6 +76,8 @@ A frontend Session Intent appears as a “New session” row and temporarily cou Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. +Deleting a Workspace registration removes its group without deleting or closing any Session. Its accounted Sessions immediately join Ungrouped, including the current Session; a reload reconstructs the same result from the independent Workspace and Session baselines. + ### React and slot boundaries React components only consume `useSessions`, `useWorkspaces`, and session-scoped hooks; they do not own entity lifecycles. The Zustand store retains only layout, the current view, composer text for ordinary real Sessions, and other purely presentational state. Session and Workspace Intents, materialization phases, errors, and retained prompts reside in the React-free runtime object layer. @@ -106,6 +109,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo - The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. - A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. - Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 8ccbf5b984..b80b75a806 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -21,10 +21,11 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | | `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | | `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 | +| `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。 +`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 @@ -51,7 +52,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。Rename、Delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编和显示名/目录名双输入不在本期范围。 +Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 @@ -75,6 +76,8 @@ Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定 无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 +删除 Workspace 注册记录会移除其分组,但不会删除或关闭任何 Session。已记账的 Session(包括当前 Session)会立即进入 Ungrouped;刷新后,独立的 Workspace 与 Session 基线会重建出相同结果。 + ### React 与 slot 边界 React 组件只消费 `useSessions`、`useWorkspaces` 与 session-scoped hooks,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。 @@ -106,6 +109,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 - 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 - UI 与 Host 两层拒绝同名 Workspace;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml new file mode 100644 index 0000000000..847b040457 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md +2026-07-27-workspace-registration-deletion.md: cae01d529bc6fd97da6fb61839bd5ec8e21557e2 +2026-07-27-workspace-registration-deletion.zh.md: 76377ebc5e93101e1e3efce1d29c3c654df032c2 diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md new file mode 100644 index 0000000000..cae01d529b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md @@ -0,0 +1,53 @@ +# Agent Note: Workspace Registration Deletion + +Status: implemented + +English | [中文](2026-07-27-workspace-registration-deletion.zh.md) + +## Problem + +A Workspace registers an existing code directory so the GUI can name it and order its Sessions. That record has no reliable provenance proving that Harness created or owns the directory, and the Session log is an independent persistence object. Treating the row's Delete action as recursive source deletion or Session deletion would destroy data outside the record's ownership boundary. + +The existing visual-only menu row also left deletion semantics undefined across durable order, the Workspace table, Host streams, concurrent browser tabs, reconnect baselines, and a list request racing the mutation. + +## Decision + +`ctx.workspace.delete(id)` deletes only the Workspace registration: its id leaves durable `workspaceIds`, its `workspaces` table row and entity-cache entry disappear, and its ordered `sessionIds` account disappears with that row. It never calls filesystem removal or `SessionPersistence`; the directory, every user file, every live Session, and every persisted Session log remain. Because sidebar grouping is the complement of all surviving Workspace accounts, those Sessions immediately appear under Ungrouped, including the current Session. + +Unknown ids return `false` at the domain seam. `workspace.delete({ workspaceId })` maps that distinction to `workspace-not-found`; success returns `{ deleted: true }`. `workspace.list` remains the reconnect baseline. + +## Durable commit and publication + +Registry operations serialize create and delete. Deletion first writes the Workspace order without the id, then removes the entity from the cache, then deletes the table row. The table deletion is the notification commit point: the package invariant accepts it only after the cache stopped publishing the entity, and the Host emits `host/workspace-removed` only from that committed deletion. A table-write failure restores the cache and prior durable order; no removal frame is published. + +The Host stream keeps its committed-id set through the preceding global-order write and removes the id only on the table deletion. Create rollback therefore emits no false removal, while every connected tab receives exactly the id needed to delete its projection. + +## Client convergence + +`WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta. + +## Confirmation interaction + +The existing Workspace row menu opens a shared `Modal` before deletion. The text states all three consequences: the Workspace leaves the list, the folder and session logs remain, and its Sessions appear under Ungrouped. While the request is pending, the confirm and Cancel controls are disabled, duplicate confirmation is ignored, and Escape or Close cannot dismiss the operation. Failure keeps the Modal open with the error; Cancel, Escape, and Close before submission never delete. + +The menu, Modal, and buttons retain their existing structure and design tokens. Session deletion remains visual-only and outside this decision. + +## Alternatives considered + +**Cascade-delete Sessions.** Rejected because Workspace registration does not own Session persistence and the product requirement is to preserve histories under Ungrouped. Session deletion needs its own lifecycle, running checks, descendant semantics, and explicit UI. + +**Move the folder to Trash.** Rejected because the record cannot prove directory ownership. A future destructive filesystem action must be separately named, separately confirmed, and enforce explicit safety boundaries. + +**Delete the table row and repair order later.** Rejected because a crash or write failure would leave an initialized registry whose order and table disagree. The registry updates both under one serialized operation and restores the prior order on table failure. + +**Refetch both lists after success.** Rejected because the committed removal frame plus immediate unary echo is sufficient, preserves the current Session object, and avoids turning a local mutation into two list requests. Reconnect baselines remain the repair path. + +## Verification + +Workspace package tests pin successful metadata-only deletion, unknown-id idempotence, table-failure rollback, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close. + +The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload. + +## Consequences + +Deleting a Workspace is intentionally reversible by registering the same directory again, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns. diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md new file mode 100644 index 0000000000..76377ebc5e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md @@ -0,0 +1,53 @@ +# Agent Note(agent 决策记录):删除 Workspace 注册记录 + +Status: implemented + +[English](2026-07-27-workspace-registration-deletion.md) | 中文 + +## Problem + +Workspace 注册已有代码目录,使 GUI 能够为目录命名,并对其会话排序。该记录没有可靠的来源信息来证明 Harness 创建或拥有该目录,会话日志也是独立的持久化对象。若将行内 Delete 操作视为递归删除源码或删除会话,就会破坏该记录所有权边界之外的数据。 + +现有菜单行仅提供视觉效果,因此持久顺序、Workspace 表、Host 流、并发浏览器标签页、重连基线,以及列表请求与变更并发时的删除语义也没有定义。 + +## Decision + +`ctx.workspace.delete(id)` 只删除 Workspace 注册记录:其 id 会从持久 `workspaceIds` 中移除,`workspaces` 表行与实体缓存条目会消失,有序 `sessionIds` 账本也随该行一并消失。它绝不调用文件系统移除操作或 `SessionPersistence`;目录、所有用户文件、所有实时会话和所有持久化会话日志都会保留。侧边栏分组是所有存续 Workspace 账本的补集,因此这些会话(包括当前会话)会立即出现在 Ungrouped 下。 + +未知 id 在 domain seam 返回 `false`。`workspace.delete({ workspaceId })` 将该结果映射为 `workspace-not-found`;成功时返回 `{ deleted: true }`。`workspace.list` 仍是重连基线。 + +## 持久提交与发布 + +注册表操作会串行执行创建与删除。删除时先写入移除该 id 后的 Workspace 顺序,再从缓存中移除实体,最后删除表行。表删除是通知提交点:只有缓存停止发布该实体后,包不变量才接受该删除;Host 也只根据这次已提交的删除发出 `host/workspace-removed`。表写入失败时,系统会恢复缓存和此前的持久顺序,且不会发布移除帧。 + +Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合,只在删除表行时移除该 id。因此,创建回滚不会发出错误的移除帧,而每个已连接标签页都能收到从自身投影中删除该记录所需的准确 id。 + +## 客户端收敛 + +`WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。 + +## 确认交互 + +现有 Workspace 行菜单会在删除前打开共享 `Modal`。文案明确说明三项后果:Workspace 会从列表中移除,文件夹和会话日志会保留,相关会话会出现在 Ungrouped 下。请求待处理期间,确认与 Cancel 控件均被禁用,重复确认会被忽略,Escape 或 Close 也无法关闭此次操作。失败时 `Modal` 保持打开并显示错误;提交前使用 Cancel、Escape 或 Close 绝不会触发删除。 + +菜单、`Modal` 和按钮保留现有结构与设计 token。会话删除仍仅提供视觉效果,不在本决策范围内。 + +## Alternatives considered + +**级联删除会话。** 不予采纳,因为 Workspace 注册记录不拥有会话持久化,且产品需求是将历史记录保留在 Ungrouped 下。会话删除需要自己的生命周期、运行状态检查、后代对象的处理语义和明确 UI。 + +**将文件夹移到废纸篓。** 不予采纳,因为该记录无法证明目录所有权。未来的破坏性文件系统操作必须使用单独名称、单独确认,并实施明确的安全边界。 + +**先删除表行,之后再修复顺序。** 不予采纳,因为崩溃或写入失败会使已初始化注册表的顺序与表不一致。注册表会在同一串行操作内更新二者,并在表操作失败时恢复此前顺序。 + +**成功后重新拉取两个列表。** 不予采纳,因为已提交的移除帧与即时一元回显已足够,既能保留当前会话对象,也避免将局部变更扩大为两次列表请求。重连基线仍是修复路径。 + +## Verification + +Workspace 包测试固定了仅删除元数据的成功路径、未知 id 的幂等行为、表操作失败回滚,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。 + +组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。 + +## Consequences + +删除 Workspace 后仍可重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。 diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml index 5ab7a8229d..6e5f8b8391 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-24-domain-kv-storage-and-workspace.md: cd666a47a3cba4dea8846cd0f1373224e6fc456f -2026-07-24-domain-kv-storage-and-workspace.zh.md: 81adf1eb6bc32aa3ca8b9ef4c352fb94f95ace91 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md +2026-07-24-domain-kv-storage-and-workspace.md: 230877628428dc88dbddeecfe5f4353cf15e151d +2026-07-24-domain-kv-storage-and-workspace.zh.md: 050f72cd3327f83e2c3f3cefcab63c01e8f112ee diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md index cd666a47a3..2308776284 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md @@ -11,7 +11,9 @@ The host's only persistence surface is the session event log (`packages/session- - **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Until now workspace was only a sidebar visual grouping derived from cwd, with no entity (that conclusion has been overturned). - **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates. -Separately, workspace deletion will eventually need to delete its owned sessions, and `SessionPersistence` has no delete primitive nor does the host expose a `session.delete` endpoint — that gap's design is settled in this note, but its implementation is marked future work: this phase touches no session-side code. +Separately, Session deletion needs a `SessionPersistence` delete primitive and a `session.delete` endpoint. That gap's design is settled in this note, but its implementation remains future work. + +The later [Workspace registration deletion decision](../../implemented/feature/2026-07-27-workspace-registration-deletion.md) supersedes only that coupling: deleting a Workspace registration preserves its Sessions and their logs, while Session deletion remains separate future work. The cascade design below is therefore not the Workspace GUI delete semantic. ## Proposal @@ -234,14 +236,14 @@ export class WorkspaceRegistry extends Service { get(id: WorkspaceId): Workspace | undefined list(): Workspace[] resolveByPath(path: string): Promise // 同 realpath 口径,故 async - // delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口 + delete(id: WorkspaceId): Promise // 只删注册记录;目录与 session 日志保留 } ``` - **Path canon**: the stored value = `fs.realpath(input)` (trailing slashes, `..`, and symlinks all resolved); uniqueness = string equality after normalization (a symlink resolving to the same directory counts as a collision). A missing directory makes create reject outright (realpath fails — a workspace must point at an existing directory; "Create new = make the directory" is upper-layer interaction: mkdir first, then create). The session cwd in attach checks follows the same canon. Single-valued cwd + unique path ⇒ one session structurally belongs to at most one workspace; double bookkeeping is impossible on the write side. - **Title**: a display name, defaults to `basename(path)`, mutable, duplicates allowed. Ownership is never derived from cwd as a fallback — cwd cannot express ordering, and ownership is a workspace-side fact; sessions started headless belong to no workspace. - Consumers see only the `Workspace` interface; `WorkspaceEntity` stays inside the package (a single implementation does not pre-split a seam). Entities are unique per id (registry cache); the record snapshot is swapped in place after each write, and the outside sees getters only. Every write funnels through the entity's internal `mutate(fn)` → `table.update`, with `updatedAt` refreshed inside mutate. Domain objects never cross RPC; next phase the wire layer projects records into zod wire schemas. -- **Workspace deletion is future work as a whole** (settled 2026-07-24): the registry ships no delete method this phase — the half-measure "delete the record, keep the sessions" is not exposed; deletion and the session cascade (`recursive` parameter, running checks, bottom-up order, crash-rerun convergence) land as one complete semantic together with the session delete primitive; the order then is delete sessions one by one → prune the ledger → delete the workspace record. +- **Session deletion remains future work.** The later [Workspace registration deletion decision](../../implemented/feature/2026-07-27-workspace-registration-deletion.md) ships `ctx.workspace.delete(id)` as a metadata-only operation that preserves Sessions and logs. Recursive Session deletion, running checks, and crash-rerun convergence belong to a separate `session.delete` capability. Consistency doctrine (the ledger = the only ownership authority; the implementation and test baseline): @@ -285,7 +287,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha | Not doing | Trigger | Rework point | Groundwork | | --- | --- | --- | --- | -| The full deletion suite (`SessionPersistence.delete`, the deleted event, `registry.delete` cascade, recursive delete, running checks) | future work starts (before the GUI needs delete interactions) | implement per the future-work section above: the session primitive + `registry.delete(id, { recursive? })` land as one | orchestration rules and rejection table settled in this note; no deletion entry exists this phase, so no half-semantics to stay compatible with | +| Session deletion (`SessionPersistence.delete`, the deleted event, recursive delete, running checks) | a destructive Session-delete product flow starts | implement the session primitive plus `session.delete`; keep it independent from Workspace registration deletion | orchestration rules and rejection table above remain groundwork; Workspace deletion preserves Sessions and logs | | The `log` facet and the session-backend migration | any phase after this one | sink the medium operations (the reuse audit table is the work list) | the facet structure is in place; both backends' medium code is organized in sinkable shape already | | Multi-process write protection | two host processes writing one medium | JSON backend file locks; SQLite WAL is natively multi-process | all writes already funnel through the domain's single point; locking touches backends only | | Cross-process change observation | GUI reconnect awareness | the revision pattern (copy session-persistence) | `domain/changed` already exists in-process | @@ -296,7 +298,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha | Cross-table atomic transactions | one business operation touching two tables of one domain atomically | `domain.transact(fn)`; JSON whole-unit rewrite is naturally atomic, SQLite wraps a transaction | — | | Secondary indexes / conditional queries | in-memory filtering stops scaling (tens of thousands of records) | SQLite JSON1 over the value column, a read-only query facet on the seam | the JSON backend does not follow | | Moving a session across workspaces | a product need appears | relax the attach check into a "detach first, then attach" orchestration | — | -| RPC/GUI/boot | next phase | `workspace.*` + `session.delete` endpoints, wire schemas, boot mounting, sidebar on real data | this phase's model and semantics are the direct source of the wire projection | +| Session-delete RPC/GUI | a destructive Session-delete product flow starts | `session.delete` endpoint, wire schema, and explicit confirmation UI | Workspace RPC/GUI is shipped separately; no cascade coupling remains | ## Alternatives considered @@ -317,7 +319,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha ## Acceptance criteria - This phase's four test suites all green: the shared backend contract suite on both json/sqlite, registry/mount disposer semantics, the domain layer (including the six open steps and fail-loud routing), and full workspace semantics (create/attach checks/consistency doctrine). -- `ctx.workspace` completes the create → attach → list lifecycle under a test assembly (deletion is future work). +- `ctx.workspace` completes the create → attach → list → metadata-only delete lifecycle under a test assembly. - Zero diff in the session-persistence packages (the acceptance line for not touching the session side this phase). - No new snapshots this phase (no model-visible or assembly surface); added next phase with the RPC wiring. diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md index 81adf1eb6b..050f72cd33 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md @@ -11,7 +11,9 @@ host 侧唯一的持久化面是 session 事件日志(`packages/session-persis - **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。此前 workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体(该结论已被推翻)。 - **session 动态元信息**(可预见的第二个消费者)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。 -另外,workspace 删除最终需要删除其关联 session,而 `SessionPersistence` 没有删除原语,host 也没有 `session.delete` 端点——该空白的设计随本 Note 定案,但实施标记为 future work:本期不动 session 侧任何代码。 +另外,Session 删除需要 `SessionPersistence` 删除原语和 `session.delete` 端点。该空白的设计随本 Note 定案,但实现仍属未来工作。 + +后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)取代的仅是上述耦合关系:删除 Workspace 注册记录会保留相关 Session 及其日志,Session 删除仍是独立的未来工作。因此,下文的级联设计并不是 Workspace GUI 的删除语义。 ## Proposal @@ -234,14 +236,14 @@ export class WorkspaceRegistry extends Service { get(id: WorkspaceId): Workspace | undefined list(): Workspace[] resolveByPath(path: string): Promise // 同 realpath 口径,故 async - // delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口 + delete(id: WorkspaceId): Promise // 只删注册记录;目录与 session 日志保留 } ``` - **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 reject(realpath 失败——workspace 必须指向存在目录;"Create new = 建目录"是上层交互,先 mkdir 再 create)。attach 校验的 session cwd 同口径。cwd 单值 + path 唯一 ⇒ 一个 session 结构上最多归属一个 workspace,双重记账写侧不可能。 - **title**:显示名,默认 `basename(path)`,可改,允许重复。归属不用 cwd 派生兜底——cwd 表达不了排序,归属是 workspace 侧事实;headless 直开的 session 不属于任何 workspace。 - 消费者只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam);实体按 id 唯一(registry 缓存),记录快照写后原地换新,外部只见 getter;所有写收敛到实体内 `mutate(fn)` → `table.update`,`updatedAt` 在 mutate 内统一刷。领域对象不过 RPC,下期 wire 层把记录投影成 zod wire schema。 -- **workspace 删除整体为 future work**(2026-07-24 拍板):本期 registry 不提供 delete 方法——半截的"只删记录留 session"语义不对外暴露,删除与 session 级联(`recursive` 参数、运行中检查、自底向上、崩溃重跑收敛)作为一个完整语义随 session 删除原语一起落地;届时顺序为逐个删 session → 摘账 → 删记录。 +- **Session 删除仍属未来工作。** 后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)已将 `ctx.workspace.delete(id)` 作为仅删除元数据、保留 Session 与日志的操作交付。递归删除 Session、运行中检查和崩溃重跑收敛属于独立的 `session.delete` 能力。 一致性口径(账 = 归属唯一依据;实现与测试基准): @@ -285,7 +287,7 @@ export class WorkspaceRegistry extends Service { | 不做 | 触发条件 | 返工点 | 预埋 | | --- | --- | --- | --- | -| 删除全套(`SessionPersistence.delete`、deleted 事件、`registry.delete` 级联、递归删、运行中检查) | future work 启动(GUI 需要删除交互前) | 按上文 future work 节实施:session 原语 + `registry.delete(id, { recursive? })` 一体落地 | 编排规则/拒绝清单已定案在本 Note;本期无任何删除入口,无半截语义要兼容 | +| Session 删除(`SessionPersistence.delete`、deleted 事件、递归删除、运行中检查) | 破坏性的 Session 删除产品流启动 | 实现 Session 原语及 `session.delete`;与 Workspace 注册记录删除保持独立 | 上文编排规则和拒绝清单仍是基础;Workspace 删除会保留 Session 与日志 | | `log` facet 与 session 后端迁移 | 本期后任意期启动 | 介质操作下沉(复用审计表即施工清单) | facet 结构已留位;两后端介质代码本期即按可下沉形状组织 | | 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁;SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 | | 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence) | 进程内已有 `domain/changed` | @@ -296,7 +298,7 @@ export class WorkspaceRegistry extends Service { | 跨表原子事务 | 同域两表一次原子操作需求 | `domain.transact(fn)`;JSON 天然原子,SQLite 包事务 | — | | 二级索引/条件查询 | 内存过滤不动(万级记录) | SQLite JSON1 查 value 列,加只读 query 面 | JSON 后端不陪跑 | | session 跨 workspace 移动 | 产品需求出现 | attach 校验放宽为"先 detach 后 attach"编排 | — | -| RPC/GUI/boot | 下期 | `workspace.*` + `session.delete` 端点、wire schema、boot 挂载、sidebar 接真数据 | 本期模型与语义即 wire 投影的直接来源 | +| Session 删除 RPC/GUI | 破坏性的 Session 删除产品流启动 | `session.delete` 端点、wire schema 与明确的确认 UI | Workspace RPC/GUI 已独立交付,不再存在级联耦合 | ## Alternatives considered @@ -317,7 +319,7 @@ export class WorkspaceRegistry extends Service { ## Acceptance criteria - 测试矩阵本期四套件全绿:backend 契约共享套件在 json/sqlite 双端、registry/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud)、workspace 全语义(create/attach 校验/一致性口径)。 -- `ctx.workspace` 可在测试组装下完成 create → attach → list 生命周期(删除为 future work)。 +- `ctx.workspace` 可在测试组装下完成 create → attach → list → 仅删除元数据的 delete 生命周期。 - session-persistence 包零 diff(本期不动 session 侧的验收线)。 - 本期无新快照(无模型可见面与组装面);下期 RPC 接线时补。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index a19211c6bf..4dbcca36ae 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -5,12 +5,13 @@ // calls: workspace.create/rename are host RPCs with no model involvement, // and the one session row the flat/hover scenarios need comes from a seeded // fixture (the seeded-history seed reused verbatim — no new recording). -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -105,6 +106,86 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete')) + // Register the scaffold's existing project directory through the real UI. + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const useFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) + await useFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) + await useFolder.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => useFolder.count(), { timeout: 10_000 }).toBe(0) + + const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) + if (workspace === undefined) throw new Error('GUI did not register the existing project directory') + await workspace.attachSession(SessionId(SEED_ID)) + const header = (await scaffold.ctx.sessionPersistence.list()) + .find(candidate => candidate.id === SEED_ID) + if (header === undefined) throw new Error('seeded Session log disappeared before deletion') + const logLocation = scaffold.ctx.sessionPersistence.locate(header) + if (logLocation === undefined) throw new Error('JSONL persistence did not expose the seeded log path') + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + + // Open the seeded (first/accounted) Session so deletion must preserve the + // current selection while it moves into Ungrouped. + const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first() + await groupRow.waitFor({ timeout: 10_000 }) + const groupSection = groupRow.locator('..') + if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click() + await expect.poll( + () => groupSection.locator('[role="treeitem"]').count(), + { timeout: 10_000 }, + ).toBeGreaterThanOrEqual(2) + const seededRow = groupSection.locator('[role="treeitem"]').nth(1) + await seededRow.click() + await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true') + + await groupRow.hover() + await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click() + await page.getByRole('menuitem', { name: 'Delete workspace' }).click() + const dialog = page.getByRole('dialog', { name: 'Delete workspace' }) + await dialog.waitFor({ timeout: 10_000 }) + const copy = await dialog.textContent() + expect(copy).toContain('workspace list') + expect(copy).toContain('folder and session logs will be kept') + expect(copy).toContain('sessions will appear under Ungrouped') + await dialog.getByRole('button', { name: 'Delete workspace' }).click() + await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) + + expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined() + await expect.poll( + () => page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).count(), + { timeout: 10_000 }, + ).toBe(0) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }) + .toBeGreaterThanOrEqual(1) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 10_000 }, + ).toBe(1) + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 15_000 }, + ).toBe(1) + expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined() + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('switches to the flat "In one list" view and persists the preference', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-flat')) // Grouped default: workspace group rows render (the seeded session sits @@ -134,12 +215,20 @@ describe('web e2e: workspace management (create / rename / flat view / hover car onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) // Expand Ungrouped to reveal the seeded session row, then dwell on it // (the card opens after a 500ms hover delay, portaled to body). - await page.getByText('Ungrouped', { exact: true }).click() - // A cold summary carries no durable title, so the row falls back to a - // cwd-derived display title — anchored on the run-local workspace-root - // basename rather than a literal. - const wsBase = scaffold.workspaceCwd.split('/').pop()! - const sessionRow = page.locator('[role="treeitem"]').filter({ hasText: wsBase }).first() + const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + const ungroupedSection = ungroupedRow.locator('..') + // Initial-current auto-expansion can race this following test's gesture; + // converge on expanded rather than assuming which update wins first. + await expect.poll(async () => { + if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { + await page.getByText('Ungrouped', { exact: true }).click() + await page.waitForTimeout(50) + } + return await ungroupedRow.getAttribute('aria-expanded') + }, { timeout: 5_000 }).toBe('true') + // The only visible child is the non-blank persisted Session; the blank + // Session created while adopting the Workspace remains hidden. + const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.hover() // Card content: the full title plus the Idle status line (display-only diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cc21210f3b..2c72632c2f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2015,6 +2015,16 @@ get(id: WorkspaceId): Workspace | undefined */ list(): Workspace[] +/** + * Delete one workspace registration while retaining its directory and every + * session log. The durable order is updated before the table deletion; a + * failed table write restores the prior order and keeps the entity + * published. Unknown ids are an idempotent no-op for domain callers. + * @param id - Workspace registration to remove. + * @returns `true` when a record was deleted, `false` when it was unknown. + */ +delete(id: WorkspaceId): Promise + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index eaab0a43f9..fcd04f2460 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -723,6 +723,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } return ok(request, { workspace: { ...workspace } }) }, + delete: (request) => { + const { workspaceId } = request.payload + const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) + if (index === -1) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + workspaces.splice(index, 1) + emitHost({ type: 'host/workspace-removed', workspaceId }) + return ok(request, { deleted: true as const }) + }, insertSessionBefore: (request) => { const { workspaceId, sessionId, beforeSessionId } = request.payload const workspace = workspaces.find(w => w.workspaceId === workspaceId) @@ -914,6 +928,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) + case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'command.list': return this.api.commands.list(request) // The in-memory execute never blocks, so a never-aborting signal is faithful here. diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bf7295cc50..1c58e604ab 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient { rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), + delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 0374f92b3b..7200291229 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -365,6 +365,31 @@ describe('createFixtureApi', () => { expect(noop.result.value.workspace.updatedAt).toBe(before) }) + it('workspace.delete removes only the Workspace row and emits the removal frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) + expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) + await consuming + expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }]) + const list = await api.workspace.list(req({})) + if (!list.result.ok) throw new Error('workspace list failed') + expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false) + const sessions = await api.sessions.list(req({})) + if (!sessions.result.ok) throw new Error('session list failed') + expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha') + }) + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() const abort = new AbortController() diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 5e73979b0b..bb4a2d4d2b 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 4724ebc75d441252245a0e811a4ae34f8b529a98 -README.zh.md: 6a0076742efccaf946910c77c77a9b74194b9dc5 +# pnpm run verify-translation-pairing --write packages/client/runtime/README.md +README.md: d2a10b3d97837ac859c52c206afab06913ea222e +README.zh.md: f23f8cb184242edbd6d19aeff5823f1efbee8eba diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4724ebc75d..d2a10b3d97 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -6,7 +6,9 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a ## Workspace and Session lists -Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. + +`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6a0076742e..f23f8cb184 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -6,7 +6,9 @@ ## Workspace 与 Session 列表 -Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 + +`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index e7caecfe82..83275e9a2a 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -19,6 +19,10 @@ export interface WorkspaceListSnapshot { error: RpcError | null } +type WorkspaceDelta = + | { type: 'upsert'; workspace: WorkspaceView } + | { type: 'remove'; workspaceId: WorkspaceId } + /** Workspace object cluster driven by one list baseline and changed-frame upserts. */ export class WorkspaceManager { private items: Workspace[] = [] @@ -28,7 +32,8 @@ export class WorkspaceManager { private phase: WorkspaceListPhase = 'pending' private error: RpcError | null = null private inflight: Promise | null = null - private refreshFrames: WorkspaceView[] | null = null + private refreshFrames: WorkspaceDelta[] | null = null + private readonly removedIds = new Set() private snapshotCache: WorkspaceListSnapshot private readonly notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() @@ -51,7 +56,7 @@ export class WorkspaceManager { this.state = 'loading' this.error = null const established = this.itemViews() - const frames: WorkspaceView[] = [] + const frames: WorkspaceDelta[] = [] this.refreshFrames = frames this.notifier.markDirty() this.inflight = (async () => { @@ -61,7 +66,8 @@ export class WorkspaceManager { let items = this.phase === 'pending' ? result.value.items : mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId) - for (const workspace of frames) items = upsertWorkspace(items, workspace) + items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) + for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) this.state = 'idle' this.phase = 'ready' @@ -111,6 +117,18 @@ export class WorkspaceManager { return result } + /** + * Delete a Workspace registration and remove its local projection from the + * unary response without waiting for the Host frame. + * @param workspaceId - target workspace. + * @returns the wire result. + */ + async delete(workspaceId: WorkspaceId): Promise> { + const { result } = await this.api.workspace.delete({ workspaceId }) + if (result.ok) this.remove(workspaceId) + return result + } + /** * Move a session within its Workspace's manual order, then publish the * returned snapshot without waiting for the changed frame. @@ -139,6 +157,7 @@ export class WorkspaceManager { */ handleHostEnvelope(envelope: RpcRequest): void { if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) + else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) } /** Re-pull the baseline after each connection generation. */ @@ -175,7 +194,8 @@ export class WorkspaceManager { /** Upsert one Host view, optionally retaining the local object that materialized it. */ private upsert(view: WorkspaceView, identity?: Workspace): void { - this.refreshFrames?.push(view) + if (this.removedIds.has(view.workspaceId)) return + this.refreshFrames?.push({ type: 'upsert', workspace: view }) const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) // Mutation responses and changed frames race (two carriers, no ordering): // reject a snapshot strictly older than the installed projection so a @@ -195,6 +215,17 @@ export class WorkspaceManager { this.notifier.markDirty() } + /** Remove one id idempotently and retain a tombstone against late echoes. */ + private remove(workspaceId: WorkspaceId): void { + this.refreshFrames?.push({ type: 'remove', workspaceId }) + this.removedIds.add(workspaceId) + const items = this.items.filter(item => + item.getSnapshot().view?.workspaceId !== workspaceId) + if (items.length === this.items.length) return + this.items = items + this.notifier.markDirty() + } + private installViews(views: readonly WorkspaceView[]): void { const existing = new Map( this.items.flatMap((workspace) => { @@ -234,3 +265,10 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi ? [workspace, ...items] : items.map((item, position) => position === index ? workspace : item) } + + +function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { + return delta.type === 'upsert' + ? upsertWorkspace(items, delta.workspace) + : items.filter(workspace => workspace.workspaceId !== delta.workspaceId) +} diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 1e281ca792..4cb26aa220 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -174,6 +174,16 @@ export class WorkspacesService { return result.value.workspace } + /** + * Delete one Workspace registration. Sessions, session logs, and the + * directory remain Host-owned outside this operation. + * @param workspaceId - target workspace. + */ + async delete(workspaceId: WorkspaceId): Promise { + const result = await this.manager.delete(workspaceId) + if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) + } + /** * Move a session within its Workspace's manual order (DOM-insertBefore-like). * @param workspaceId - owning workspace. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index dcb334f6ea..9f6147cdd3 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -96,6 +96,9 @@ export class FakeApiClient implements IApiClient { onWorkspaceRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + onWorkspaceDelete: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ deleted: true })) + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) @@ -103,6 +106,7 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index d020b74fec..210c04d896 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -76,6 +76,48 @@ describe('WorkspaceManager', () => { ok: false, error: { code: 'internal', message: 'create transport' }, }) }) + + it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const manager = new WorkspaceManager(api) + const hydration = manager.refresh() + manager.handleHostEnvelope({ + rpcId: 'removed' as never, + payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, + }) + gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] })) + await hydration + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + + manager.handleHostEnvelope({ + rpcId: 'late-change' as never, + payload: { type: 'host/workspace-changed', workspace: workspace('gone') }, + }) + manager.handleHostEnvelope({ + rpcId: 'duplicate-remove' as never, + payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, + }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + }) + + it('removes from the unary delete echo while a refresh is in flight', async () => { + const api = new FakeApiClient() + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] })) + const manager = new WorkspaceManager(api) + await manager.refresh() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const refresh = manager.refresh() + + await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }]) + expect(manager.getSnapshot().items).toEqual([]) + gate.resolve(ok({ items: [workspace('gone')] as never[] })) + await refresh + expect(manager.getSnapshot().items).toEqual([]) + }) }) describe('WorkspacesService', () => { @@ -175,4 +217,20 @@ describe('WorkspacesService', () => { })) await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/) }) + + it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) + await workspaces.refresh() + await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined() + expect(workspaces.list.getSnapshot().items).toEqual([]) + + api.onWorkspaceDelete = () => Promise.resolve(err({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' }, + })) + await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) + }) }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index d0f2d0a20a..88638b9e35 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33 -README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010 +# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md +README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259 +README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index e0247b3e26..b5a78c30dd 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No Workspace rename/delete controls** — the picker supports selection and creation only. +- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions. - **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 92ef463faa..904543a48f 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -18,5 +18,5 @@ ## 已知限制与暂缓事项 -- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建。 +- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 - **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index d6375cb698..c03d511c92 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -258,6 +258,16 @@ color: var(--dsw-alias-state-error-primary); } +.deleteAction:not(:disabled) { + color: var(--dsw-alias-state-error-primary); +} + +.deleteStatus { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + @media (prefers-reduced-motion: reduce) { .wide { animation: none; diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 0dc6485929..0090164928 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -87,10 +87,15 @@ type SessionTreeProps = Pick< query: string /** Open the browser-owned rename dialog for a real Workspace group. */ onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void + /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */ + onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ -function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) { +function SessionTree({ + useSessions, startSession, open, workspaces, query, + onRenameRequest, onDeleteRequest, insertSessionBefore, +}: SessionTreeProps) { const list = useSessions((s) => s) const current = list.current const [expandedProjects, setExpandedProjects] = useState([]) @@ -128,11 +133,17 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen onCreate={() => { if (group.workspaceId !== undefined) startSession(group.workspaceId) }} - onRename={group.workspaceId === undefined + actions={group.workspaceId === undefined ? undefined - : () => { - /* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */ - if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + : { + rename: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + }, + delete: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label) + }, }} /> {group.sessions.map((node, index) => { @@ -236,6 +247,7 @@ export function WorkspaceBrowser({ startSession, open, renameWorkspace, + deleteWorkspace, insertSessionBefore, createWorkspace, }: WorkspaceBrowserProps) { @@ -291,6 +303,30 @@ export function WorkspaceBrowser({ }) } + // Delete dialog is separate from the row so a successful removal can + // unmount that row without tearing down the in-flight confirmation state. + const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null) + const [deleting, setDeleting] = useState(false) + const [deleteError, setDeleteError] = useState(null) + const closeDelete = () => { + if (deleting) return + setDeleteTarget(null) + setDeleteError(null) + } + const confirmDelete = () => { + /* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */ + if (deleting || deleteTarget === null) return + setDeleting(true) + setDeleteError(null) + deleteWorkspace(deleteTarget.workspaceId).then(() => { + setDeleting(false) + setDeleteTarget(null) + }).catch((reason: unknown) => { + setDeleting(false) + setDeleteError(reason instanceof Error ? reason.message : String(reason)) + }) + } + return (
    @@ -382,6 +418,10 @@ export function WorkspaceBrowser({ setRenameDraft(currentTitle) setRenameError(null) }} + onDeleteRequest={(workspaceId, title) => { + setDeleteTarget({ workspaceId, title }) + setDeleteError(null) + }} /> ))}
    @@ -416,6 +456,30 @@ export function WorkspaceBrowser({ )} {renameError !== null &&
    {renameError}
    } + + + + + )} + > + {deleting &&
    Deleting workspace…
    } + {deleteError !== null &&
    {deleteError}
    } +
    ) } diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 6008da553f..2e4c88dd6b 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -32,6 +32,8 @@ export type WorkspaceBrowserInjected = { open: (sessionId: SessionId) => void /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */ renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise + /** Delete only a Host Workspace registration; directory and Session logs remain. */ + deleteWorkspace: (workspaceId: WorkspaceId) => Promise /** * Reorder a session inside its Workspace account (DOM-insertBefore * semantics: omitted anchor appends to the end). The view refreshes from diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index a444464441..98adfecce3 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -39,6 +39,7 @@ export function apply(ctx: ClientContext): void { startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, open: (sessionId) => { ctx.sessions.open(sessionId) }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, + deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index a100da83e9..ae866f58cf 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -3,7 +3,7 @@ * all data and callbacks arrive via props. Hover swaps (folder->chevron, * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only * except workspace Rename; the session hover card is suppressed while a menu - * is open. + * is open. Workspace Rename/Delete are wired; session actions remain visual-only. */ import { useState } from 'react' import clsx from 'clsx' @@ -39,12 +39,12 @@ const WORKSPACE_MENU_ITEMS = [ * @param props.onCreate - start a frontend Session inside this Workspace. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions }: { group: GroupNode onToggle: () => void onCreate: () => void - /** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */ - onRename?: (() => void) | undefined + /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ + actions?: { rename: () => void; delete: () => void } | undefined }) { const row = group const active = group.expanded && group.containsCurrent @@ -68,15 +68,15 @@ export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { {count} - {onRename !== undefined && ( + {actions !== undefined && ( { setMenuOpen(false) }} items={WORKSPACE_MENU_ITEMS} onSelect={(id) => { setMenuOpen(false) - if (id === 'rename') onRename() - // Delete is visual-only for now. + if (id === 'rename') actions.rename() + else actions.delete() }} portal closeOnPointerLeave diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 70cfb36940..6ccd923793 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -98,12 +98,16 @@ describe('workspace browser rows', () => { it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { const onRename = vi.fn() + const onDelete = vi.fn() const onToggle = vi.fn() const group: GroupNode = { key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], } - render() + render() fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) // Opening the menu neither toggles the group nor renames yet. expect(onToggle).not.toHaveBeenCalled() @@ -111,11 +115,11 @@ describe('workspace browser rows', () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) expect(onRename).toHaveBeenCalledOnce() expect(screen.queryByRole('menu')).toBeNull() - // Delete stays visual-only: selecting it just closes the menu. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) expect(screen.queryByRole('menu')).toBeNull() expect(onRename).toHaveBeenCalledOnce() + expect(onDelete).toHaveBeenCalledOnce() // Escape closes without selecting (Menu onClose path). fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) fireEvent.keyDown(document, { key: 'Escape' }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e9b55e7b76..1dbe895b74 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -54,6 +54,7 @@ function mount(overrides: Partial = {}) { startSession: vi.fn(), open: vi.fn(), renameWorkspace: vi.fn(async () => {}), + deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), ...overrides, @@ -457,6 +458,74 @@ describe('WorkspaceBrowser', () => { await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) }) + it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => { + let resolveDelete!: () => void + const deleteWorkspace = vi.fn(() => new Promise((resolve) => { resolveDelete = resolve })) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])), + deleteWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + const dialog = screen.getByRole('dialog', { name: 'Delete workspace' }) + expect(dialog.textContent).toContain('removes “Alpha” from the workspace list') + expect(dialog.textContent).toContain('folder and session logs will be kept') + expect(dialog.textContent).toContain('sessions will appear under Ungrouped') + + const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement + fireEvent.click(confirm) + fireEvent.click(confirm) + expect(deleteWorkspace).toHaveBeenCalledOnce() + expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha')) + expect(confirm.disabled).toBe(true) + expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByRole('status').textContent).toBe('Deleting workspace…') + fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() + await act(async () => { resolveDelete() }) + expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() + }) + + it('keeps the delete dialog open on failure and allows retry or cancellation', async () => { + const deleteWorkspace = vi.fn() + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockRejectedValueOnce('denied') + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + deleteWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') }) + expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() + }) + + it('Cancel, Escape, and Close dismiss deletion without calling the action', () => { + const deleteWorkspace = vi.fn(async () => {}) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + deleteWorkspace, + }) + const open = () => { + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + } + open() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + open() + fireEvent.keyDown(document, { key: 'Escape' }) + open() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(deleteWorkspace).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() + }) + it('search hides drag affordances (rows are not draggable during search)', () => { const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })]) mount({ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 224e8300ab..f33722a1fc 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -942,6 +942,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'list(): Workspace[]', jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */', }, + { + signature: 'delete(id: WorkspaceId): Promise', + jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */', + }, { signature: 'async resolveByPath(path: string): Promise', jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index eb06e14d2d..653906ba08 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f -README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10 +# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md +README.md: dc29abdc10f536463358db92a7ac25c1579f2a50 +README.zh.md: a69d51de086dfbc692dccf3f5f88ce7e36c74e9c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 43ad70fa8b..dc29abdc10 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. -Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cc95a7512f..a69d51de08 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -12,7 +12,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f81f5c9be8..34e67edef9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -764,6 +764,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { workspace: workspaceView(workspace) }) }, + async delete(request) { + const { workspaceId } = request.payload + const operation = workspaceCreationChain.then(() => + ctx.workspace.delete(brandWorkspaceId(workspaceId))) + workspaceCreationChain = operation.then(() => undefined, () => undefined) + if (!await operation) return workspaceNotFound(request, workspaceId) + return ok(request, { deleted: true as const }) + }, + async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) @@ -977,8 +986,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) })) }), ctx.on('domain/changed', (change) => { - if (change.domain !== 'workspace' || change.operation !== 'put') return + if (change.domain !== 'workspace') return if (change.table === '') { + if (change.operation !== 'put') return const state = workspaceDomainState.parse(change.value) for (const workspaceId of state.workspaceIds) { if (committedWorkspaceIds.has(workspaceId)) continue @@ -991,7 +1001,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return } - if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return + if (change.table !== 'workspaces') return + if (change.operation === 'deleted') { + if (!committedWorkspaceIds.delete(change.key)) return + queue.push(frame({ + type: 'host/workspace-removed', + workspaceId: change.key as WorkspaceId, + })) + return + } + if (!committedWorkspaceIds.has(change.key)) return // Existing-entity table writes are complete attach/touch commits. // A new entity's first put waits for the global registry write above. queue.push(frame({ diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index e95b371c54..973db5a91e 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts' import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts' import { approvalRequestIdSchema } from './approvals.schema.ts' import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts' -import { workspaceViewSchema } from './workspace.schema.ts' +import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' /** Question shape validated strictly against core dsh-user-interaction. */ export const askUserQuestionItemSchema = z.object({ @@ -47,6 +47,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }), z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), + z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), z.object({ type: z.literal('host/commands-changed') }), z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index db572215cb..bf66cbf76b 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -85,7 +85,9 @@ export type MuxFrame = * agent-error is the only outlet for live failures with no turn position; * workspace-changed pushes the full new snapshot after every durable * workspace mutation (create/attach/order change — the client upserts, while - * `workspace.list` provides the reconnect baseline). + * `workspace.list` provides the reconnect baseline); workspace-removed is the + * committed registration-deletion increment and never implies directory or + * session-log deletion. */ export type HostFrame = | { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string } @@ -93,6 +95,7 @@ export type HostFrame = | { type: 'host/session-status'; sessionId: SessionId; running: boolean } | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } + | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } /** * The command registry changed (`commands/change` passthrough). Pure * invalidation signal, no payload: clients refetch `command.list` in the diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index abe992584c..68ccc9ec89 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -26,6 +26,7 @@ export interface RpcMethodMap { 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] + 'workspace.delete': WorkspaceApi['delete'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 47c3ae6d59..e16e5339da 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -59,6 +59,16 @@ export const workspaceRenameValueSchema = z.object({ workspace: workspaceViewSchema, }) satisfies z.ZodType>> +/** workspace.delete request payload. */ +export const workspaceDeleteRequestSchema = z.object({ + workspaceId: workspaceIdSchema, +}) satisfies z.ZodType>> + +/** workspace.delete response value. */ +export const workspaceDeleteValueSchema = z.object({ + deleted: z.literal(true), +}) satisfies z.ZodType>> + /** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ export const workspaceInsertSessionBeforeRequestSchema = z.object({ workspaceId: workspaceIdSchema, diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 6ec636126b..ff22d845fb 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -65,6 +65,14 @@ export interface WorkspaceApi { rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>): Promise> + /** + * Removes one Workspace registration. The directory, every user file, and + * every session log remain untouched; those Sessions consequently become + * ungrouped. An unknown id fails with `workspace-not-found`. + */ + delete(request: RpcRequest<{ workspaceId: WorkspaceId }>): + Promise> + /** * Moves an accounted session within its workspace's manual order, * DOM-insertBefore-like: with `beforeSessionId` the session is inserted diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0424ba7a4f..8762670cd7 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -23,6 +23,7 @@ import { } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, + workspaceDeleteValueSchema, workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, workspaceRenameValueSchema, @@ -60,6 +61,7 @@ export interface IApiClient { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> + delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> } commands: { @@ -91,6 +93,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.list', payload, signal), create: (payload, signal) => this.callUnary('workspace.create', payload, signal), rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), + delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index b79980d63e..3bbcbffba1 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -24,6 +24,7 @@ import { import { hostDescribeRequestSchema } from '../api/host.schema.ts' import { workspaceCreateRequestSchema, + workspaceDeleteRequestSchema, workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, workspaceRenameRequestSchema, @@ -57,6 +58,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, + 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 11cdf5795c..d5ba628590 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -244,4 +244,31 @@ describe('Host Workspace increments', () => { abort.abort() expect(await next).toMatchObject({ done: true }) }) + + it('deletes the registration, keeps its session and folder, and streams one removal', async () => { + const { api, ctx } = await harness() + const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace + const sessionId = SessionId('session-kept-after-workspace-delete') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) + + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const removed = nextHostFrame(stream) + expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))) + expect(await removed).toMatchObject({ + payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId }, + }) + expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) + expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) + expect(ctx.agents.get(sessionId)).toBeDefined() + expect(existsSync(workspace.path)).toBe(true) + + const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId })) + expect(missing.result).toMatchObject({ + ok: false, + error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } }, + }) + abort.abort() + }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index a9a5eac9ba..38ad7a52c9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -40,6 +40,7 @@ function scriptedApi(overrides: { list: r => ok(r, { items: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + delete: r => ok(r, { deleted: true as const }), insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), }, commands: { @@ -76,13 +77,15 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } }) }) - it('routes workspace rename and insertSessionBefore through the wire', async () => { + it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => { const api = scriptedApi() const c = client(api) const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) expect(renamed.result.ok).toBe(true) const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' }) expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) + const deleted = await c.workspace.delete({ workspaceId: 'w1' as never }) + expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) expect(anchored.result.ok).toBe(true) const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index e8d65d2a62..0a92b9f5e5 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -58,6 +58,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, } }, + async delete(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } } + }, async insertSessionBefore(request) { return { rpcId: request.rpcId, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 02ca8dec22..c1af02b7b9 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -14,6 +14,7 @@ import { import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, + workspaceDeleteRequestSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, workspaceListRequestSchema, workspaceListValueSchema, workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, @@ -177,6 +178,13 @@ describe('workspace domain schemas', () => { expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') }) + it('validates workspace deletion payload and receipt', () => { + expect(workspaceDeleteRequestSchema.parse({ workspaceId: 'w1' }).workspaceId).toBe('w1') + expect(() => workspaceDeleteRequestSchema.parse({})).toThrow() + expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true }) + expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow() + }) + it('insertSessionBefore accepts an anchored and an anchorless move', () => { expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() @@ -273,6 +281,11 @@ describe('events frame schemas', () => { { type: 'host/session-removed', sessionId: 's' }, { type: 'host/session-status', sessionId: 's', running: true }, { type: 'host/agent-error', sessionId: 's', message: 'boom' }, + { type: 'host/workspace-changed', workspace: { + workspaceId: 'w', path: '/w', title: 'w', sessionIds: [], + createdAt: '0', updatedAt: '0', + } }, + { type: 'host/workspace-removed', workspaceId: 'w' }, { type: 'host/commands-changed' }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] diff --git a/packages/workspace/README.i18n.yaml b/packages/workspace/README.i18n.yaml index a5400bc218..6d62c08c0b 100644 --- a/packages/workspace/README.i18n.yaml +++ b/packages/workspace/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 0d5ebabfbbb2922a369adb3a5d67ea4aafbe700f -README.zh.md: b82e8e6138f3e97c3c047cf1812cee8e558ea29b +# pnpm run verify-translation-pairing --write packages/workspace/README.md +README.md: ba92e95d3cde0a95eaaeae5a9b4384c3b8c9c4b8 +README.zh.md: 8c8146bba5fa6d81c0ce5d2ed29add77b4083270 diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 0d5ebabfbb..ba92e95d3c 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -2,10 +2,10 @@ English | [中文](README.zh.md) -The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). +The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md). | Package | Role | ctx key | |---|---|---| | `workspace/` | `WorkspaceRegistry` service over the storage domain form: realpath-unique paths, session-ownership accounting, entity cache | `ctx.workspace` | -Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deletion (workspace and session cascade) is deliberately absent this phase and ships with the session-side primitives. +Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deleting a Workspace removes only this registry record and account: directories, user files, and session logs remain, and the Sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)). diff --git a/packages/workspace/README.zh.md b/packages/workspace/README.zh.md index b82e8e6138..8c8146bba5 100644 --- a/packages/workspace/README.zh.md +++ b/packages/workspace/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 +Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)。 | 包 | 职责 | ctx 键 | |---|---|---| | `workspace/` | 位于存储领域形式之上的 `WorkspaceRegistry` 服务:按 realpath 唯一的路径、会话所有权计数、实体缓存 | `ctx.workspace` | -所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。本阶段有意不提供删除(workspace 与会话级联);该功能将与会话侧原语一起交付。 +所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。删除 Workspace 只会移除该注册表记录及账本:目录、用户文件和会话日志都会保留,相关会话则进入 Ungrouped(参见[决策记录](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。 diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index b3e0df9280..0904711ad3 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 0d395ecc58fc5e3362cb5f3c565a0539bb09c4dd -README.zh.md: 017e1e4d3aae9f8708ead3565f8b5b59d9b249ca +# pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md +README.md: 52d03b33b3482dcb6a2f5feddbc15ac9fefee0a8 +README.zh.md: f899abdc3dd2a551179cd710c6dda84f804a8e80 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 0d395ecc58..52d03b33b3 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -10,6 +10,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; a different path cannot create a duplicate title. - `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. +- `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. - `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes. - `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. @@ -35,5 +36,5 @@ Independent of live requests: the package never touches a request prefix, so it ## Known Limitations and Deferred Work -- No delete entry point in this phase — workspace deletion ships as one complete semantic together with the session-delete primitive and cascade orchestration (future-work section of the Agent Note); a half "drop the record, keep the sessions" operation is deliberately not exposed. +- Session deletion and destructive folder removal are separate, absent capabilities; Workspace registration deletion never substitutes for either ([decision](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)). - The header index refreshes at startup and when attach must resolve an uncached persisted id; deletion or cwd damage performed by another process is observed after the next refresh or restart. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 017e1e4d3a..f899abdc3d 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -10,6 +10,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径不能创建重复标题。 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它应用同一 `realpath` 规范,并会拒绝缺失路径,而不是创建路径。 +- `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话账本。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、实时会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 - `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话为空操作,workspace 顺序绝不改变。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、从两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 @@ -35,5 +36,5 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 ## 已知限制与延后工作 -- 本阶段没有删除入口:workspace 删除将与会话删除原语和级联编排一起作为完整语义交付(参见 Agent Note 的未来工作一节);系统有意不公开「删除记录、保留会话」的半成品操作。 +- 会话删除与破坏性的文件夹移除是彼此独立且尚未提供的功能;删除 Workspace 注册记录绝不能替代二者(参见[决策记录](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。 - 头部索引会在启动时刷新,也会在 attach 必须解析未缓存持久 id 时刷新;另一进程执行的删除或 cwd 破坏会在下次刷新或重启后被观测。 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 0f849e7374..2699365608 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -168,6 +168,18 @@ export class WorkspaceRegistry extends Service { }) } + /** + * Delete one workspace registration while retaining its directory and every + * session log. The durable order is updated before the table deletion; a + * failed table write restores the prior order and keeps the entity + * published. Unknown ids are an idempotent no-op for domain callers. + * @param id - Workspace registration to remove. + * @returns `true` when a record was deleted, `false` when it was unknown. + */ + delete(id: WorkspaceId): Promise { + return this.enqueueOperation(() => this.deleteKnown(id)) + } + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -231,6 +243,33 @@ export class WorkspaceRegistry extends Service { return entity } + private async deleteKnown(id: WorkspaceId): Promise { + const entity = this.entities.get(id) + if (entity === undefined) return false + const state = this.requireState() + const nextState = { + initialized: true, + workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), + } + await this.setState(nextState) + this.entities.delete(id) + try { + await this.requireTable().delete(id) + } catch (error) { + this.entities.set(id, entity) + try { + await this.setState(state) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `workspace '${id}' record deletion and registry-order rollback both failed`, + ) + } + throw error + } + return true + } + private async bootstrap(headers: readonly SessionHeader[]): Promise { const table = this.requireTable() const state = this.requireState() diff --git a/packages/workspace/workspace/src/invariant.ts b/packages/workspace/workspace/src/invariant.ts index 1764ce2fe3..808ce1dedf 100644 --- a/packages/workspace/workspace/src/invariant.ts +++ b/packages/workspace/workspace/src/invariant.ts @@ -20,8 +20,9 @@ export const inject = ['invariants'] * domain's durable table. Every `domain/changed` for the `workspaces` table * must name a record the cache already holds an entity for (the registry * caches before the durable put and mutates only through cached entities). - * A delete is valid only for create rollback, after the provisional cache - * entry has been removed; deleting a published entity proves a bypass. + * A delete is valid only after the registry has removed the entity from its + * cache, whether for create rollback or an explicit registration deletion; + * deleting while the cache still publishes the entity proves a bypass. */ const install: InvariantInstaller = Object.assign( (ctx: Context, fail: (message: string) => never) => { diff --git a/packages/workspace/workspace/tests/invariant.spec.ts b/packages/workspace/workspace/tests/invariant.spec.ts index ea1fbaa64c..0d0556a44a 100644 --- a/packages/workspace/workspace/tests/invariant.spec.ts +++ b/packages/workspace/workspace/tests/invariant.spec.ts @@ -49,7 +49,7 @@ describe('workspace cache/table invariant', () => { .toThrow(/cache still publishes/) }) - it('allows deletion only after a provisional create cache entry was removed for rollback', async () => { + it('allows deletion after the registry removed the cache entry for rollback or explicit deletion', async () => { const ctx = await setup([]) expect(() => { ctx.emit('domain/changed', deleted()) }).not.toThrow() }) diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 8ce70cc7d5..ed08b5ba50 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -424,6 +424,40 @@ describe('WorkspaceRegistry create and lookup', () => { expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1) }) + it('deletes only the registration and leaves its directory and session headers untouched', async () => { + const dir = await makeDir('delete-registration') + const result = await harness({ sessions: [header('kept-session', dir)] }) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('kept-session')) + + await expect(result.registry.delete(workspace.id)).resolves.toBe(true) + await expect(result.registry.delete(workspace.id)).resolves.toBe(false) + expect(result.registry.get(workspace.id)).toBeUndefined() + expect(result.registry.list()).toEqual([]) + expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false) + await expect(realpath(dir)).resolves.toBe(dir) + expect(result.list).toHaveBeenCalledTimes(1) + expect(result.load).not.toHaveBeenCalled() + expect(result.inspect).not.toHaveBeenCalled() + }) + + it('rolls registry order and cache back when record deletion fails', async () => { + const dir = await makeDir('delete-rollback') + const pool = new MemoryMediaPool() + const result = await harness({ + pool, + backend: selectiveFailureBackend(pool, { deleteAt: 1 }), + }) + const workspace = await result.registry.create(dir) + + await expect(result.registry.delete(workspace.id)).rejects.toThrow(/selected rollback delete failure/) + expect(result.registry.get(workspace.id)).toBe(workspace) + expect(result.registry.list()).toEqual([workspace]) + expect(storedState(pool).workspaceIds).toEqual([workspace.id]) + expect(storedRecord(pool, workspace.id)).toMatchObject({ path: dir }) + }) + it('rejects table access before the registry has started', async () => { const dir = await makeDir('unstarted') const registry = new WorkspaceRegistry(new Context()) From b052cd11613d4343fae8fb19f6df6f68275731f1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:39:29 +0800 Subject: [PATCH 24/56] docs(exp-wine): record measured warm-cache result and the queued 8-core leg --- .../2026-07-27-wine-windows-gates-experiment.i18n.yaml | 4 ++-- .../process/2026-07-27-wine-windows-gates-experiment.md | 2 ++ .../process/2026-07-27-wine-windows-gates-experiment.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index fb51fef157..c39841966d 100644 --- a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: 9e2db947eceee7e3e2fee63f8fe2ac90de1cd13d -2026-07-27-wine-windows-gates-experiment.zh.md: a4b938faa6ae27bd068db9a952ebb1432ec7ca3f +2026-07-27-wine-windows-gates-experiment.md: 47a37ddb48f4321f916c7f7a0cb96ae80b133103 +2026-07-27-wine-windows-gates-experiment.zh.md: 3a912861110a06b39bfb2c37395fc6a061bdfbe6 diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md index 9e2db947ec..47a37ddb48 100644 --- a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md @@ -18,6 +18,8 @@ Dependencies install natively on Linux with `supportedArchitectures` extended to The lane targets the wall clock of the Linux CI jobs (about two minutes), from four levers: the master-refreshed pnpm store cache (restore-only, same key as ci.yml), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image so Wine's package downloads are paid once per image version. +Measured on 2026-07-27: 2m46s end-to-end on a warm-cache pull-request run (setup and cache restores ~17s, concurrent install+provision 33s, concurrent gates 110s), against 1.5–2.5 minutes for the Linux CI jobs and 7–9 minutes for the paid Windows lane; a cold-cache run pays roughly one extra minute. The 8-core benchmark leg never left the queue — the restricted `dsh-ubuntu-*` pools were also observed queueing indefinitely from the sibling KVM experiment — so the standard-runner number stands as the result, and no larger box is needed to hit the target. + This is deliberately a fidelity probe, not a drop-in replacement: Wine reimplements the Win32 API over a case-sensitive ext4 (NTFS case-insensitivity is not emulated by default), provides no ConPTY, and substitutes its own security-descriptor and `MoveFileExW` semantics — exactly the surfaces the repo's `win32.ts` modules and PTY backend care about. The experiment measures which blocking gates pass, which fail for Wine reasons rather than product reasons, and the wall-clock cost relative to the recorded Windows benchmark lanes. Promotion, if the verdict is positive: fold the Wine lane in as the pull-request Windows signal for blocking gates and demote the real-Windows pool to the master serial reference; otherwise record the failure class here and keep the pool. diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md index a4b938faa6..3a91286111 100644 --- a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -18,6 +18,8 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表 该通道以 Linux CI 作业的墙钟(约两分钟)为目标,靠四个杠杆:master 刷新的 pnpm store 缓存(只恢复,与 ci.yml 同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,使 Wine 的包下载每个镜像版本只付一次。 +2026-07-27 实测:热缓存 pull request 运行端到端 2 分 46 秒(准备与缓存恢复约 17 秒,并发安装+供给 33 秒,并发门禁 110 秒),对照 Linux CI 作业的 1.5–2.5 分钟与付费 Windows 通道的 7–9 分钟;冷缓存约多付一分钟。8 核基准腿从未离开队列——受限的 `dsh-ubuntu-*` 池在兄弟 KVM 实验中也被观察到无限排队——因此标准 runner 的数字即为结果,达标不需要更大的机器。 + 这刻意是一次保真度探针,而非直接替换:Wine 在大小写敏感的 ext4 之上重实现 Win32 API(默认不模拟 NTFS 的大小写不敏感)、不提供 ConPTY、并用自己的安全描述符与 `MoveFileExW` 语义替代——恰是本仓库 `win32.ts` 模块与 PTY 后端关心的表面。实验度量哪些阻断门禁通过、哪些因 Wine 原因而非产品原因失败,以及相对已记录 Windows 基准通道的墙钟成本。 若结论为正则晋升:把 Wine 通道并入为 pull request 的阻断门禁 Windows 信号,将真实 Windows 池降级为 master 串行参照;否则在此记录失败类别并保留该池。 From 109b469a7e52a1a62e9355833001a0257bb7740d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:41:36 +0800 Subject: [PATCH 25/56] fix(tool-web): bound conversion depth and complete fetch output Two review findings on the turndown swap, both verified empirically: - Unclosed-tag nesting makes the synchronous turndown/domino walk superlinear (measured: depth 512 ~0.15s, 2k ~2s, 20k ~5s), during which the cooperative fetchTimeoutMs timer cannot fire. renderBody now preflights nesting depth with a linear tag scan and passes bodies past 512 levels through raw; the try/catch stays for markup the scan cannot see (comment-hidden tags), simulated in tests via a converter throw. - Markdown escaping can expand converted HTML ~2x (100k underscores render as 200k chars), so provider body caps no longer bounded the model-visible result. formatFetchOutput now caps the complete output (header + body + footer) under new fetchMaxOutputChars config (default 200000 = 2x the local provider's default body cap), reusing the truncation notice. README EN+ZH, config catalog, Agent Note EN+ZH updated; the new web-fetch fixture is migrated to the packed layout master now requires; tool-web coverage stays 100% per-file. --- ...ndown-for-tool-web-html-markdown.i18n.yaml | 4 +- ...-26-turndown-for-tool-web-html-markdown.md | 4 +- ...-turndown-for-tool-web-html-markdown.zh.md | 4 +- docs/config-catalog.md | 6 +- .../tests/snapshots/web-fetch/session.jsonl | 102 +----------------- packages/web/tool-web/README.i18n.yaml | 4 +- packages/web/tool-web/README.md | 5 +- packages/web/tool-web/README.zh.md | 5 +- packages/web/tool-web/src/fetch.ts | 86 ++++++++++++--- packages/web/tool-web/src/index.ts | 19 +++- packages/web/tool-web/tests/tool-web.spec.ts | 70 ++++++++++-- 11 files changed, 172 insertions(+), 137 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml index 60a5d9aca7..a114e32885 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-26-turndown-for-tool-web-html-markdown.md: c72decc336055f3b78dafdf98f2be3771b833cdb -2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 30667b62538ec50608cae461b5cdf651b48e2731 +2026-07-26-turndown-for-tool-web-html-markdown.md: c7ef4bf538cc949eec8463c8a2ac750685d1a715 +2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 3104dac3cd6db516396b6773f5d2185f3da22ca3 diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md index c72decc336..c7ef4bf538 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md @@ -10,7 +10,7 @@ English | [中文](2026-07-26-turndown-for-tool-web-html-markdown.zh.md) ## Decision -`packages/web/tool-web/src/fetch.ts` owns a module-level [`turndown`](https://github.com/mixmark-io/turndown) instance (`headingStyle: 'atx'`, `codeBlockStyle: 'fenced'`, `bulletListMarker: '-'` — fixed model-facing presentation, not deployment tunables) with `@joplin/turndown-plugin-gfm`'s composite `gfm` plugin for tables/strikethrough and `remove(['script', 'style', 'noscript'])` replacing the old wholesale drops. `renderBody`'s `html` arm calls it in a try/catch falling back to the raw HTML body: the regex version could never throw, while turndown/domino's recursive DOM walk overflows with a `RangeError` at a few thousand nesting levels (measured: 4k throws on the main thread, 8k in a worker thread), and a degraded page beats an error for a body the provider already decoded. `html.ts` and its conversion tests are deleted; the fallback and the status-header/truncation-footer formatting are tested in `tests/tool-web.spec.ts`, and the README's Known Limitations trades the regex-converter caveat for the pathological-nesting fallback. The gfm plugin ships no types; `src/turndown-plugin-gfm.d.ts` declares the one imported export over `@types/turndown` (a devDependency). +`packages/web/tool-web/src/fetch.ts` owns a module-level [`turndown`](https://github.com/mixmark-io/turndown) instance (`headingStyle: 'atx'`, `codeBlockStyle: 'fenced'`, `bulletListMarker: '-'` — fixed model-facing presentation, not deployment tunables) with `@joplin/turndown-plugin-gfm`'s composite `gfm` plugin for tables/strikethrough and `remove(['script', 'style', 'noscript'])` replacing the old wholesale drops. `renderBody`'s `html` arm guards the conversion twice: a linear tag-scan preflight passes bodies nested past 512 levels through raw (the synchronous walk is superlinear on unclosed nesting — measured seconds at 20k levels — during which the cooperative timeout cannot fire), and a try/catch falls back to the raw HTML when turndown still throws on markup the scan cannot see; a degraded page beats an error for a body the provider already decoded. `formatFetchOutput` bounds the complete output (`fetchMaxOutputChars` config, default 200,000) because markdown escaping can expand converted HTML to ~2× a provider's body cap. `html.ts` and its conversion tests are deleted; the fallback and the status-header/truncation-footer formatting are tested in `tests/tool-web.spec.ts`, and the README's Known Limitations trades the regex-converter caveat for the pathological-nesting fallback. The gfm plugin ships no types; `src/turndown-plugin-gfm.d.ts` declares the one imported export over `@types/turndown` (a devDependency). The dependency-weight question the proposal flagged resolves in favor of the swap: `@deepseek-ai/dsh-tool-web` is in the single-file-executable closure ([single-exe note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)), and the exe's asset globs would pack ~7.9 MB of the three packages as published — but ~6 MB of that is `@mixmark-io/domino`'s test corpus (`test/**`), with runtime `lib/` at ~550 KB against a ~174 MB artifact, under 0.5% either way. @@ -33,5 +33,5 @@ The previously-missing keyless `web_fetch` snapshot ships with the change as the ## Testing -- `packages/web/tool-web/tests/tool-web.spec.ts` covers the turndown conversion surface (entities, links, tables, nesting, script/style/noscript removal) through `renderBody`, and the raw-HTML fallback with a measured reliably-overflowing 20k-level nesting input; per-file coverage on the package src is 100%. +- `packages/web/tool-web/tests/tool-web.spec.ts` covers the turndown conversion surface (entities, links, tables, nesting, script/style/noscript removal) through `renderBody`, the fast raw-HTML passthrough for 20k-level nesting, the depth scan's void/self-closing/unbalanced cases, the residual converter-throw fallback, and the whole-output cap at expanding, exact, and tiny budgets; per-file coverage on the package src is 100%. - The `web-fetch` acp-agent snapshot pins the assembled behavior keylessly end to end (real Loader composition, real HTTP fetch, real conversion). diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md index 30667b6253..3104dac3cd 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`packages/web/tool-web/src/fetch.ts` 持有一个模块级 [`turndown`](https://github.com/mixmark-io/turndown) 实例(`headingStyle: 'atx'`、`codeBlockStyle: 'fenced'`、`bulletListMarker: '-'`——固定的面向模型呈现方式,不是部署可调项),配合 `@joplin/turndown-plugin-gfm` 的组合 `gfm` 插件提供表格/删除线支持,并用 `remove(['script', 'style', 'noscript'])` 替代旧实现的整体剥离。`renderBody` 的 `html` 分支把调用包在 try/catch 中,失败时回退为原始 HTML 主体:正则版本从不可能抛异常,而 turndown/domino 的递归 DOM 遍历在数千层嵌套(实测:主线程 4k 层抛出,worker 线程 8k 层抛出)会以 `RangeError` 栈溢出,对提供方已经解码的主体来说,降级页面好过报错。`html.ts` 及其转换测试已删除;回退路径与状态头、截断页脚的格式化在 `tests/tool-web.spec.ts` 中有测试覆盖,README 的 Known Limitations 用病态嵌套回退条目替换了正则转换器的警示说明。gfm 插件不带类型声明;`src/turndown-plugin-gfm.d.ts` 基于 `@types/turndown`(devDependency)声明了唯一被导入的导出。 +`packages/web/tool-web/src/fetch.ts` 持有一个模块级 [`turndown`](https://github.com/mixmark-io/turndown) 实例(`headingStyle: 'atx'`、`codeBlockStyle: 'fenced'`、`bulletListMarker: '-'`——固定的面向模型呈现方式,不是部署可调项),配合 `@joplin/turndown-plugin-gfm` 的组合 `gfm` 插件提供表格/删除线支持,并用 `remove(['script', 'style', 'noscript'])` 替代旧实现的整体剥离。`renderBody` 的 `html` 分支对转换做了双重防护:一次线性标签扫描预检把嵌套超过 512 层的主体直接原样透传(同步遍历在未闭合嵌套上呈超线性——实测 2 万层需要数秒——期间协作式超时无法触发),扫描看不到的标记若仍让 turndown 抛异常,则由 try/catch 回退为原始 HTML;对提供方已经解码的主体来说,降级页面好过报错。`formatFetchOutput` 对完整输出设上限(`fetchMaxOutputChars` 配置,默认 200,000):markdown 转义可能把转换后的 HTML 膨胀到提供方主体上限的约 2 倍。`html.ts` 及其转换测试已删除;透传、回退与整体输出上限,连同状态头、截断页脚的格式化,都在 `tests/tool-web.spec.ts` 中有测试覆盖,README 的 Known Limitations 用病态嵌套回退条目替换了正则转换器的警示说明。gfm 插件不带类型声明;`src/turndown-plugin-gfm.d.ts` 基于 `@types/turndown`(devDependency)声明了唯一被导入的导出。 提案标记的依赖体积问题的裁决结果支持替换:`@deepseek-ai/dsh-tool-web` 在单文件可执行文件闭包内([single-exe 决策记录](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)),可执行文件的资产 glob 会把这三个包按发布原样打入约 7.9 MB——但其中约 6 MB 是 `@mixmark-io/domino` 的测试语料(`test/**`),运行时 `lib/` 仅约 550 KB,相对约 174 MB 的产物,两种口径都不到 0.5%。 @@ -33,5 +33,5 @@ Status: implemented ## 测试 -- `packages/web/tool-web/tests/tool-web.spec.ts` 通过 `renderBody` 覆盖 turndown 转换面(实体、链接、表格、嵌套、script/style/noscript 移除),并用实测可稳定溢出的 2 万层嵌套输入覆盖原始 HTML 回退;该包 src 的逐文件覆盖率为 100%。 +- `packages/web/tool-web/tests/tool-web.spec.ts` 通过 `renderBody` 覆盖 turndown 转换面(实体、链接、表格、嵌套、script/style/noscript 移除)、2 万层嵌套的快速原样透传、深度扫描的空元素/自闭合/不平衡用例、残余的转换器抛错回退,以及在膨胀、恰好、极小预算下的整体输出上限;该包 src 的逐文件覆盖率为 100%。 - acp-agent 的 `web-fetch` 快照无密钥地端到端固定组装后的行为(真实 Loader 组合、真实 HTTP 抓取、真实转换)。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 721c432f73..742d04dd71 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1656,7 +1656,7 @@ Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tas Requires: `tools` · `web` · `systemPrompt` ```ts config-catalog -/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */ +/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */ export interface Config { /** Register `web_search`. Defaults to true. */ search?: boolean @@ -1668,10 +1668,12 @@ export interface Config { fetchTimeoutMs?: number /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */ searchTimeoutMs?: number + /** Cap on one `web_fetch` output's characters (header, rendered body, and footer). Defaults to 200000. */ + fetchMaxOutputChars?: number } ``` -Source: [`packages/web/tool-web/src/index.ts:28`](../packages/web/tool-web/src/index.ts) +Source: [`packages/web/tool-web/src/index.ts:37`](../packages/web/tool-web/src/index.ts) ## `@deepseek-ai/dsh-tool-workflow` diff --git a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl index c6c34bc8e3..47c97b1cba 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl +++ b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl @@ -5,75 +5,9 @@ {"type":"step/start","seq":3,"time":1785078727730,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785078727731,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785078728804,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1785078728805,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1785078728943,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1785078728989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1785078728989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1785078728989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1785078728990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":12,"time":1785078728990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":13,"time":1785078728990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" web"}}} -{"type":"assistant/chunk","seq":14,"time":1785078729038,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} -{"type":"assistant/chunk","seq":15,"time":1785078729038,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"etch"}}} -{"type":"assistant/chunk","seq":16,"time":1785078729039,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":17,"time":1785078729039,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":18,"time":1785078729085,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} -{"type":"assistant/chunk","seq":19,"time":1785078729086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":20,"time":1785078729086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" fetch"}}} -{"type":"assistant/chunk","seq":21,"time":1785078729086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" http"}}} -{"type":"assistant/chunk","seq":22,"time":1785078729086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"://"}}} -{"type":"assistant/chunk","seq":23,"time":1785078729086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"127"}}} -{"type":"assistant/chunk","seq":24,"time":1785078729132,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":25,"time":1785078729133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"0"}}} -{"type":"assistant/chunk","seq":26,"time":1785078729133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":27,"time":1785078729133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"0"}}} -{"type":"assistant/chunk","seq":28,"time":1785078729133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":29,"time":1785078729133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":30,"time":1785078729182,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":31,"time":1785078729182,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"431"}}} -{"type":"assistant/chunk","seq":32,"time":1785078729183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"17"}}} -{"type":"assistant/chunk","seq":33,"time":1785078729183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"/m"}}} -{"type":"assistant/chunk","seq":34,"time":1785078729183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"enu"}}} -{"type":"assistant/chunk","seq":35,"time":1785078729183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".html"}}} -{"type":"assistant/chunk","seq":36,"time":1785078729230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":37,"time":1785078729230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":38,"time":1785078729230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":39,"time":1785078729230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":40,"time":1785078729230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":41,"time":1785078729231,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":42,"time":1785078729276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":43,"time":1785078729277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":44,"time":1785078729277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":45,"time":1785078729277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":46,"time":1785078729322,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":47,"time":1785078729323,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":48,"time":1785078729323,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":49,"time":1785078729323,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785078728805,"data":{"turn":1,"step":1,"index":0,"dt":[138,46,0,0,1,0,0,48,0,1,0,46,1,0,0,0,0,46,1,0,0,0,0,49,0,1,0,0,0,47,0,0,0,0,1,45,1,0,0,45,1,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," web","_f","etch"," tool"," exactly"," once"," to"," fetch"," http","://","127",".","0",".","0",".","1",":","431","17","/m","enu",".html",","," then"," reply"," with"," exactly"," \"","D","ONE","\"."," Let"," me"," do"," that","."]}} {"type":"assistant/chunk","seq":50,"time":1785078729463,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":51,"time":1785078729464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":52,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":53,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":54,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"url"}}} -{"type":"assistant/chunk","seq":55,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1785078729511,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":57,"time":1785078729557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":1785078729557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"http"}}} -{"type":"assistant/chunk","seq":59,"time":1785078729557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"://"}}} -{"type":"assistant/chunk","seq":60,"time":1785078729558,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"127"}}} -{"type":"assistant/chunk","seq":61,"time":1785078729604,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":62,"time":1785078729604,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"0"}}} -{"type":"assistant/chunk","seq":63,"time":1785078729604,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":64,"time":1785078729604,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"0"}}} -{"type":"assistant/chunk","seq":65,"time":1785078729604,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":66,"time":1785078729605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":67,"time":1785078729651,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":68,"time":1785078729652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"431"}}} -{"type":"assistant/chunk","seq":69,"time":1785078729652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"17"}}} -{"type":"assistant/chunk","seq":70,"time":1785078729652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"/m"}}} -{"type":"assistant/chunk","seq":71,"time":1785078729652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"enu"}}} -{"type":"assistant/chunk","seq":72,"time":1785078729652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":".html"}}} -{"type":"assistant/chunk","seq":73,"time":1785078729697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":74,"time":1785078729698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","argumentsDelta":"}"}}} +{"type":"tool-call-chunks","seq0":51,"time0":1785078729464,"data":{"turn":1,"step":1,"index":1,"dt":[47,0,0,0,0,46,0,0,1,46,0,0,0,0,1,46,1,0,0,0,0,45,1],"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","args":["","{","\"","url","\"",": ","\"","http","://","127",".","0",".","0",".","1",":","431","17","/m","enu",".html","\"","}"]}} {"type":"assistant/chunk","seq":75,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."}}}} {"type":"assistant/chunk","seq":76,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}}}} {"type":"assistant/chunk","seq":77,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}}}} @@ -84,37 +18,7 @@ {"type":"step/end","seq":82,"time":1785078729847,"data":{"turn":1,"step":1}} {"type":"step/start","seq":83,"time":1785078729848,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":84,"time":1785078730611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":85,"time":1785078730612,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":86,"time":1785078730770,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":87,"time":1785078730824,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":88,"time":1785078730825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":89,"time":1785078730825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":90,"time":1785078730825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" fetch"}}} -{"type":"assistant/chunk","seq":91,"time":1785078730861,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":92,"time":1785078730862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" URL"}}} -{"type":"assistant/chunk","seq":93,"time":1785078730909,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":94,"time":1785078730956,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":95,"time":1785078731002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":96,"time":1785078731003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":97,"time":1785078731003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":98,"time":1785078731003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":99,"time":1785078731050,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":100,"time":1785078731050,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":101,"time":1785078731050,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":102,"time":1785078731050,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":103,"time":1785078731050,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ve"}}} -{"type":"assistant/chunk","seq":104,"time":1785078731051,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" fetched"}}} -{"type":"assistant/chunk","seq":105,"time":1785078731097,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":106,"time":1785078731140,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":107,"time":1785078731141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":108,"time":1785078731141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":109,"time":1785078731141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":110,"time":1785078731189,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":111,"time":1785078731189,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":112,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":113,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":114,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":115,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"reasoning-chunks","seq0":85,"time0":1785078730612,"data":{"turn":1,"step":2,"index":0,"dt":[158,54,1,0,0,36,1,47,47,46,1,0,0,47,0,0,0,0,1,46,43,1,0,0,48,0,46,0,0,0],"texts":["The"," user"," asked"," me"," to"," fetch"," the"," URL",","," then"," reply"," with"," exactly"," \"","D","ONE","\"."," I","'ve"," fetched"," it","."," Now"," I"," just"," reply"," with"," \"","D","ONE","\"."]}} {"type":"assistant/chunk","seq":116,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"assistant/chunk","seq":117,"time":1785078731236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} {"type":"assistant/chunk","seq":118,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index 1e746ed566..44279a66be 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 5fe48ced81a2cd02197cf8cc10a7d6567b17ffca -README.zh.md: 34ad08e290166ee6db2cd7b836746541d18aad52 +README.md: 44cb1ba2a2f4e1fba7e192d8b6645e0447ebf221 +README.zh.md: 35b390dd5407af16d84ab391dd8351f784c60035 diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 5fe48ced81..44cb1ba2a2 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -26,8 +26,9 @@ The normalized seam results are also the canonical tool values: `WebSearchResult | `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). | | `fetchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_fetch`. | | `searchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_search`. | +| `fetchMaxOutputChars` | `200000` | Cap on one `web_fetch` output's characters — header, rendered body, and footer together; a cut body gets the truncation notice. | -`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md); the model-facing schema exposes no timeout argument. +`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md); the model-facing schema exposes no timeout argument. `fetchMaxOutputChars` bounds the complete rendered output because markdown escaping can expand converted HTML past a provider's body cap (worst case ~2×); the default is 2× the local provider's default 100,000-character body cap, so it never cuts what that bound already admits. ```yaml - id: tool-web @@ -126,6 +127,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **HTML→markdown conversion falls back to raw HTML on pathological input** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts fetched HTML through a real DOM, but its recursive walk overflows on absurdly deep nesting (thousands of levels); such a body passes through unconverted rather than erroring ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)). +- **HTML→markdown conversion falls back to raw HTML on pathological input** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts fetched HTML through a real DOM, but the synchronous walk is superlinear on deep unclosed nesting, so bodies nested past a fixed 512-level preflight bound pass through unconverted (as does anything that still makes turndown throw) rather than stalling the event loop or erroring ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)). - **The model-facing surface is minimal by design, with promotions deferred** — `max_results` stays a config bound (not a model argument), and `web_fetch` takes only `url` (no `format`/`prompt`/LLM-summarization mode); both are named later steps in [the seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md). - **No web-specific permission policy** — both tools execute without requesting `ctx.approval`; a deployment that needs confirmation must add a `tools/pre-execute` policy, and the package does not define persistent URL/domain grants. diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index 34ad08e290..35b390dd54 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -26,8 +26,9 @@ | `searchMaxResults` | `8` | 一次 `web_search` 调用返回的源数量上限(seam 截断更长的提供方列表并标记)。 | | `fetchTimeoutMs` | `30000` | `web_fetch` 的协作式工具调用超时预算(ms)。 | | `searchTimeoutMs` | `30000` | `web_search` 的协作式工具调用超时预算(ms)。 | +| `fetchMaxOutputChars` | `200000` | 单次 `web_fetch` 输出的字符上限——状态头、渲染后的主体与页脚合并计算;被截断的主体带截断提示。 | -`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。 +`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 对完整渲染输出设上限:markdown 转义可能让转换后的 HTML 超出提供方的主体上限(最坏约 2 倍);默认值取本地提供方默认 100,000 字符主体上限的 2 倍,因此绝不会削减该上限本已允许的内容。 ```yaml - id: tool-web @@ -126,6 +127,6 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex ## 已知限制与暂缓事项 -- **HTML→markdown 转换在病态输入上回退为原始 HTML**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换抓取到的 HTML,但其递归遍历在极深嵌套(数千层)上会栈溢出;此类主体不经转换原样通过,而非报错([决策记录](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 +- **HTML→markdown 转换在病态输入上回退为原始 HTML**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换抓取到的 HTML,但同步遍历在深层未闭合嵌套上呈超线性,因此嵌套超过固定 512 层预检上限的主体不经转换原样通过(仍让 turndown 抛异常的输入同样如此),而非阻塞事件循环或报错([决策记录](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 - **面向模型的表层有意保持最小,提升项暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM 摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。 - **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久 URL/domain 授权。 diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 60c0f33507..e909ea25be 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -44,23 +44,69 @@ export function parseFetchArgs(args: { url: string }): { url: string } { return { url: args.url } } +/** + * Nesting-depth ceiling above which HTML skips conversion and passes through + * raw. Conversion runs synchronously on the event loop, and unclosed-tag + * nesting makes domino's tree (and turndown's walk over it) superlinear — + * measured: depth 512 ≈ 0.15s, 2,000 ≈ 2s, 20,000 ≈ 5s — during which the + * cooperative `fetchTimeoutMs` timer cannot fire. Real pages nest a few dozen + * levels; 512 is far above content and far below weaponizable. A robustness + * invariant, not a tunable. + */ +const MAX_CONVERSION_DEPTH = 512 + +/** Elements that never take a closing tag, so they must not count toward nesting depth. */ +const VOID_ELEMENTS = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'link', 'meta', 'param', 'source', 'track', 'wbr', +]) + +/** + * Estimate the maximum element nesting depth of an HTML string with one linear + * tag scan. Overestimates when markup-like text sits inside `script`/`style` + * bodies or comments (the scan does not parse those), which can only cause a + * spurious raw-HTML fallback, never a missed bound. + * + * @param html - the decoded HTML body. + * @returns the deepest open-element count the scan reaches. + */ +export function htmlNestingDepth(html: string): number { + let depth = 0 + let max = 0 + for (const tag of html.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*?(\/?)>/g)) { + const [, closing, rawName = '', selfClosing] = tag + const name = rawName.toLowerCase() + if (VOID_ELEMENTS.has(name) || selfClosing === '/') continue + if (closing === '/') { + if (depth > 0) depth -= 1 + } else { + depth += 1 + if (depth > max) max = depth + } + } + return max +} + /** * Render a fetched body to model-facing markdown text. * * @param body - the decoded body; `html` is converted via turndown, `text` - * passes through verbatim. When turndown throws (deeply pathological HTML - * overflows its recursive DOM walk), the raw HTML passes through instead — - * a degraded page beats an error for a body the provider already decoded. + * passes through verbatim. HTML nested beyond {@link MAX_CONVERSION_DEPTH} + * skips conversion up front (the synchronous walk over such trees is + * superlinear and blocks the event loop past the cooperative timeout), and + * when turndown itself throws the raw HTML passes through instead — a + * degraded page beats an error for a body the provider already decoded. * @returns the text for the tool's output block. */ export function renderBody(body: WebFetchBody): string { switch (body.kind) { case 'html': + if (htmlNestingDepth(body.content) > MAX_CONVERSION_DEPTH) return body.content try { return turndown.turndown(body.content) } catch { - // turndown's DOM walk recurses per element; pathological nesting (a - // few thousand levels) throws RangeError. Provider errors stay + // turndown's DOM walk recurses per element; malformed markup the depth + // scan cannot see can still throw RangeError. Provider errors stay // structured WebErrors upstream; conversion failure downgrades to raw HTML. return body.content } @@ -72,17 +118,28 @@ export function renderBody(body: WebFetchBody): string { } } +/** The truncation notice appended when the provider or the output cap cut content. */ +const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' + /** - * Format a fetch result as one model-facing text block. + * Format a fetch result as one model-facing text block, bounded as a whole. + * Markdown escaping can expand converted HTML (worst case ~2× the provider's + * body cap), so the bound applies here, where the complete output — header, + * rendered body, and footer — is known. * * @param result - the seam's fetch outcome. + * @param maxOutputChars - cap on the complete returned string; a cut body gets + * the same fetch-something-narrower notice as provider-side truncation. * @returns a `Fetched (HTTP )` header, the rendered body, and a - * fetch-something-narrower notice when the provider truncated the content. + * truncation notice when the provider or the cap cut the content. */ -export function formatFetchOutput(result: WebFetchResult): string { - const header = `Fetched ${result.url} (HTTP ${result.statusCode})` - const footer = result.truncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : '' - return `${header}\n\n${renderBody(result.body)}${footer}` +export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string { + const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n` + const body = renderBody(result.body) + const full = `${header}${body}${result.truncated ? TRUNCATION_FOOTER : ''}` + if (full.length <= maxOutputChars) return full + const budget = Math.max(0, maxOutputChars - header.length - TRUNCATION_FOOTER.length) + return `${header}${body.slice(0, budget)}${TRUNCATION_FOOTER}` } /** @@ -102,8 +159,11 @@ export function presentFetchCall(args: { url: string }): GenericCallView { * registrations; both are effect-scoped and unregister on plugin dispose. * @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's * `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce. + * @param maxOutputChars - cap on the complete rendered tool output (see + * {@link formatFetchOutput}); markdown escaping can outgrow the provider's + * body cap, so the model-context bound is enforced on the rendered result. */ -export function applyWebFetchTool(ctx: Context, timeoutMs: number): void { +export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChars: number): void { ctx.systemPrompt.section({ name: 'tool:web_fetch', order: 111, @@ -147,7 +207,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void { truncated: { type: 'boolean', required: true }, }, }, - render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }], + render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }], }, timeoutMs, // Provider reads do not mutate parent-agent state. diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index e7ac4b2453..4a0ea5202c 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -13,7 +13,7 @@ import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts' import { applyWebFetchTool } from './fetch.ts' export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts' -export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, renderBody } from './fetch.ts' +export { applyWebFetchTool, formatFetchOutput, htmlNestingDepth, parseFetchArgs, presentFetchCall, renderBody } from './fetch.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-web' @@ -24,7 +24,16 @@ export const inject = ['tools', 'web', 'systemPrompt'] /** Default cooperative tool-call timeout budget (ms) for the web tools. */ export const DEFAULT_WEB_TOOL_TIMEOUT_MS = 30_000 -/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */ +/** + * Default cap on one `web_fetch` output's characters. Markdown escaping can + * roughly double converted HTML, so this sits at 2× the local provider's + * default 100,000-char body cap: it never cuts what that composition's + * provider bound already admits, while restoring a model-context bound for + * providers with larger or absent body caps. + */ +export const DEFAULT_FETCH_MAX_OUTPUT_CHARS = 200_000 + +/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */ export interface Config { /** Register `web_search`. Defaults to true. */ search?: boolean @@ -36,6 +45,8 @@ export interface Config { fetchTimeoutMs?: number /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */ searchTimeoutMs?: number + /** Cap on one `web_fetch` output's characters (header, rendered body, and footer). Defaults to 200000. */ + fetchMaxOutputChars?: number } export const Config: z = z.object({ @@ -44,6 +55,7 @@ export const Config: z = z.object({ searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS), fetchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS), searchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS), + fetchMaxOutputChars: z.number().default(DEFAULT_FETCH_MAX_OUTPUT_CHARS), }) /** The shape after schemastery applies its defaults to every field. */ @@ -71,6 +83,7 @@ export function apply(ctx: Context, config: Config): void { assertPositiveInteger('searchMaxResults', resolved.searchMaxResults) assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs) assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs) + assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars) if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs) - if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs) + if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars) } diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index f9ffb1b5c5..79af9cbd2a 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import TurndownService from 'turndown' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' @@ -9,6 +10,7 @@ import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import { formatSearchOutput, formatFetchOutput, + htmlNestingDepth, parseSearchArgs, parseFetchArgs, presentSearchCall, @@ -92,11 +94,13 @@ describe('search formatting', () => { }) describe('fetch formatting', () => { + const NO_CAP = 1_000_000 + it('renders an html body to markdown text with a status header', () => { const out = formatFetchOutput({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'html', content: '

    Title

    Body text

    ' }, - }) + }, NO_CAP) expect(out).toContain('Fetched https://a.test (HTTP 200)') expect(out).toContain('# Title') expect(out).toContain('Body text') @@ -106,11 +110,37 @@ describe('fetch formatting', () => { const out = formatFetchOutput({ url: 'https://a.test', statusCode: 200, truncated: true, body: { kind: 'text', content: 'plain' }, - }) + }, NO_CAP) expect(out).toContain('plain') expect(out).toContain('Content truncated') }) + it('caps the complete output and notes truncation, even when markdown escaping expands the body', () => { + // 1,000 underscores render as 2,000 escaped characters — conversion can + // outgrow a provider-side body cap, so the bound applies to the output. + const out = formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html', content: `

    ${'_'.repeat(1000)}

    ` }, + }, 500) + expect(out.length).toBeLessThanOrEqual(500) + expect(out).toContain('Fetched https://a.test (HTTP 200)') + expect(out).toContain('\\_\\_') + expect(out).toContain('Content truncated') + // Exact and tiny caps: the complete result is bounded, header and footer included. + const exact = formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'text', content: 'abc' }, + }, 'Fetched https://a.test (HTTP 200)\n\nabc'.length) + expect(exact).toBe('Fetched https://a.test (HTTP 200)\n\nabc') + const tiny = formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: true, + body: { kind: 'text', content: 'abcdef' }, + }, 10) + expect(tiny).toContain('Fetched https://a.test (HTTP 200)') + expect(tiny).toContain('Content truncated') + expect(tiny).not.toContain('abcdef') + }) + it('renderBody dispatches on kind', () => { expect(renderBody({ kind: 'text', content: 'x' })).toBe('x') expect(renderBody({ kind: 'html', content: '

    y

    ' })).toBe('y') @@ -129,14 +159,38 @@ describe('fetch formatting', () => { .toBe('**bold _italic_**\n\n> quoted') }) - it('falls back to the raw html body when turndown throws on pathological nesting', { timeout: 60_000 }, () => { - // Nesting past V8's default stack overflows turndown/domino's recursive - // walk with a RangeError (measured: 4k levels throw on the main thread, - // 8k in a worker); 20k adds margin over either stack size. The raw body - // must pass through instead of throwing. + it('passes deeply nested html through raw without attempting conversion', () => { + // Unclosed-tag nesting makes the synchronous conversion superlinear + // (seconds at 20k levels, during which the cooperative timeout cannot + // fire), so the depth preflight skips conversion entirely; this must + // return fast, not merely not-throw. const depth = 20_000 const pathological = '
    '.repeat(depth) + 'x' + '
    '.repeat(depth) + const started = Date.now() expect(renderBody({ kind: 'html', content: pathological })).toBe(pathological) + expect(Date.now() - started).toBeLessThan(2_000) + }) + + it('htmlNestingDepth counts open elements, ignoring void and self-closing tags', () => { + expect(htmlNestingDepth('

    x

    ')).toBe(2) + expect(htmlNestingDepth('

    ')).toBe(1) + expect(htmlNestingDepth('

    x

    ')).toBe(1) + expect(htmlNestingDepth('plain text, no tags')).toBe(0) + expect(htmlNestingDepth('
    '.repeat(600))).toBe(600) + }) + + it('falls back to the raw html when turndown throws despite a shallow depth scan', () => { + // Comments hide markup from the depth scan by design (it may only + // over-count, never under-count real elements); simulate the residual + // turndown failure path with a converter throw instead. + const spy = vi.spyOn(TurndownService.prototype, 'turndown').mockImplementation(() => { + throw new RangeError('Maximum call stack size exceeded') + }) + try { + expect(renderBody({ kind: 'html', content: '

    x

    ' })).toBe('

    x

    ') + } finally { + spy.mockRestore() + } }) it('validates url (non-empty), no timeout parameter', () => { From 5fa74343aab77f543c3dfdac2ee7d387e769132c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 12:54:35 +0800 Subject: [PATCH 26/56] docs(ci): six always-on instances, no pre-registered spares The spare tier is retired. Steady-state pool load is one serial standby job per master push, so six always-on instances already are the failover capacity; pre-registered offline runners are a silently expiring guarantee (GitHub garbage-collects them after 30 days offline). Incident-time extra capacity is a one-minute org-token registration, now documented in the runbook. --- ...-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 6 +++--- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- ...2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 6 +++--- .../process/2026-07-26-ci-failover-runbook.md | 9 +++------ .../process/2026-07-26-ci-failover-runbook.zh.md | 9 +++------ 6 files changed, 14 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 84a10e5ab9..5ebd95248c 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: 21e602b2b5850176df981dcf448f4f827b756719 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: ba49ff18ac304f4078d4c8ebfd00bb1a85ada0b3 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +2026-07-22-evidence-based-larger-hosted-runners.md: 5b399be5571ddaf1f775ba43a2233198b8e09b18 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 40970ec33c1a16af85ea47be3fc932209efdd654 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 21e602b2b5..5b399be557 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -52,7 +52,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index ba49ff18ac..40970ec33c 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.zh.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index a2725da1b2..efb5fdd1cc 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-26-ci-failover-runbook.md: db8e0676ecc6eeaea16438e7868ccf9ac43887cc -2026-07-26-ci-failover-runbook.zh.md: b3b4149f460784e88ce03458fc556f402c38fa2f +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +2026-07-26-ci-failover-runbook.md: 0bce83e0f9c842fa3dd73ae9c0a3eefc0975cdae +2026-07-26-ci-failover-runbook.zh.md: 4bc6c67bab754ad3f0127557b0d5e04f7934c8a2 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index db8e0676ec..0bce83e0f9 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -14,7 +14,7 @@ Each of the three required Linux jobs resolves its runner pool through the `DSH_ ### What the in-house pool is -`vm-backup`: one 64-core VM, four always-on systemd-managed runner instances, four registered spares. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. +`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. ### Switch (repo admin, ~1 minute, no merge) @@ -24,15 +24,12 @@ Each of the three required Linux jobs resolves its runner pool through the `DSH_ ### Capacity during failover -Four always-on instances absorb normal PR traffic. If queues build, bring the four registered spares online on the VM (no token needed — they are already registered): +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner) — cloning an existing runner directory and running `config.sh` takes about a minute per instance. -```bash -for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done -``` ### Switch back -Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Stop the spare instances if they were started. +Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Remove any extra instances that were registered during the incident. ### Trust boundary diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index b3b4149f46..4bc6c67bab 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 自有池是什么 -`vm-backup`:一台 64 核虚拟机,4 个常驻 systemd 管理的运行器实例,另有 4 个已注册备用位。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 +`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 ### 切换步骤(仓库管理员,约 1 分钟,无需合并) @@ -24,15 +24,12 @@ Status: implemented ### 切换期间的容量 -4 个常驻实例可承接正常 PR 流量。若出现排队,在虚拟机上把 4 个已注册的备用位拉起(无需 token——它们已注册): +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例——复制现有 runner 目录再跑 `config.sh`,每个约一分钟。 -```bash -for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done -``` ### 切回 -删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若启动过备用实例,将其停止。 +删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若故障期间追加注册过实例,将其移除。 ### 信任边界 From 55fc87a7a018f29d677c4509d6bee96910b18698 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 27 Jul 2026 12:58:20 +0800 Subject: [PATCH 27/56] fix: cr --- ...-07-27-user-message-icon-actions.i18n.yaml | 4 +- .../2026-07-27-user-message-icon-actions.md | 4 +- ...2026-07-27-user-message-icon-actions.zh.md | 4 +- .../src/client/chat/MessageItem.module.css | 18 ++++--- .../src/client/chat/MessageItem.tsx | 7 ++- .../client/toolviews/bash-sample.module.css | 9 ++++ .../src/client/toolviews/bash-sample.tsx | 12 +++++ .../tests/coverage-tails.spec.tsx | 2 + .../ui-primitives/src/markdown/CodeBlock.tsx | 30 +++++++----- .../ui-primitives/tests/code-block.spec.tsx | 48 +++++++++++++------ 10 files changed, 99 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml index 52fa9a6cb3..e664ec81fb 100644 --- a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-27-user-message-icon-actions.md: 45856e1ee093b1bfeaebfe67339aec7b6d2dd694 -2026-07-27-user-message-icon-actions.zh.md: ea87b8036ee91e8998f1e45dbea17f9ee76c244c +2026-07-27-user-message-icon-actions.md: 869e7a2518a3ec927c0689a10816a410dc5f0862 +2026-07-27-user-message-icon-actions.zh.md: 353e5ac765bb2fbab9932449cf2247768a1f412f diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md index 45856e1ee0..869e7a2518 100644 --- a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md @@ -10,7 +10,7 @@ The chat user bubble had no under-bubble action chrome. The Harness design (figm ## Decision -`MessageItem` owns the actions for `kind: 'user'` only. Layout is a column (`align-items: flex-end`, 6px gap): bubble, then a 28px action row with 10px gaps and 28px circular icon buttons (`IconCopyOutline16`, `IconBranchOutline16`, `IconEditOutline16`). Tooltips carry Chinese labels. The row stays `opacity: 0` until the user row is hovered or focus-within, per the [web styling](../../../../docs/web-styling.md) message action-bar rule. +`MessageItem` owns the actions for `kind: 'user'` only. Layout is a column (`align-items: flex-end`, 6px gap): bubble, then a 28px action row with 10px gaps and 28px circular icon buttons (`IconCopyOutline16`, `IconBranchOutline16`, `IconEditOutline16`). Tooltips carry Chinese labels. Actions stay visible by default; `@media (hover: hover)` hides them until the row is hovered or focus-within, so touch / `hover: none` devices keep discoverable controls (opacity alone still hit-tests). Copy writes the bubble's joined text blocks to the clipboard (`navigator.clipboard.writeText`, with an `execCommand` fallback). Branch and edit are present chrome with no handlers yet — they reserve the design seats without inventing session-fork or edit-resubmit behavior. @@ -20,7 +20,7 @@ Steering bubbles keep the badge-only form and do not show these actions. **Wire branch/edit to real session fork and draft-edit now.** Rejected for this change: those product flows are not specified; shipping inert buttons matches the requested scope and avoids half-built mutation paths. -**Always-visible actions (no hover fade).** Rejected against the standing action-bar rule; the figma node shows the resting chrome, not the idle-hidden state the style guide requires. +**Always hide with `opacity: 0` outside hover.** Rejected for touch: without `@media (hover: hover)`, idle opacity still hit-tests while looking empty. Hover-capable pointers keep the fade; others keep the actions visible. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md index ea87b8036e..353e5ac765 100644 --- a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -仅当 `kind: 'user'` 时,`MessageItem` 拥有这些操作。布局为纵向列(`align-items: flex-end`,间距 6px):先是气泡,再是高度 28px 的操作行;行内间距 10px,圆形图标按钮尺寸为 28px(`IconCopyOutline16`、`IconBranchOutline16`、`IconEditOutline16`)。Tooltip 承载中文标签。按 [Web 样式](../../../../docs/web-styling.md) 的消息操作栏规则,该行保持 `opacity: 0`,直到用户行被悬停或处于 focus-within 状态。 +仅当 `kind: 'user'` 时,`MessageItem` 拥有这些操作。布局为纵向列(`align-items: flex-end`,间距 6px):先是气泡,再是高度 28px 的操作行;行内间距 10px,圆形图标按钮尺寸为 28px(`IconCopyOutline16`、`IconBranchOutline16`、`IconEditOutline16`)。Tooltip 承载中文标签。操作默认保持可见;`@media (hover: hover)` 下在悬停或 focus-within 前隐藏,以便触摸/`hover: none` 设备仍能发现控件(仅靠 opacity 仍会命中测试)。 复制将气泡内拼接后的文本块写入剪贴板(`navigator.clipboard.writeText`,并以 `execCommand` 作为回退)。分支与编辑目前仅有外观、尚无处理函数——它们预留设计席位,但不发明会话 fork 或编辑重提交流程。 @@ -20,7 +20,7 @@ steering(中途引导)气泡保持仅徽章形态,不展示这些操作。 **现在就把分支/编辑接到真实的会话 fork 与草稿编辑。**本次变更不予采纳:这些产品流程尚未定稿;交付无行为按钮符合请求范围,也避免半成品的变更路径。 -**操作始终可见(无悬停淡入)。**与现行操作栏规则冲突,不予采纳;figma 节点展示的是静止态外观,而非样式指南要求的空闲隐藏状态。 +**在悬停外始终以 `opacity: 0` 隐藏。**因触摸不予采纳:若无 `@media (hover: hover)`,空闲 opacity 看起来空白但仍会命中测试。具备悬停能力的指针保留淡入;其他设备保持操作可见。 ## 后果 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 0d2331a199..22537f9cfe 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -25,14 +25,20 @@ align-items: center; gap: 10px; height: 28px; - /* Hidden until the row is hovered/focused (web-styling message action bar). */ - opacity: 0; - transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); } -.userRow:hover .actions, -.userRow:focus-within .actions { - opacity: 1; +/* Hover-capable pointers: hide until the row is hovered/focused. Touch / + hover:none keeps actions visible (opacity:0 still hit-tests). */ +@media (hover: hover) { + .actions { + opacity: 0; + transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); + } + + .userRow:hover .actions, + .userRow:focus-within .actions { + opacity: 1; + } } .action { diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 67abe94ef6..4ecfdadf88 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -30,9 +30,14 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown return { text: texts.join(''), rest } } +/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */ async function writeClipboard(text: string): Promise { if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text) + try { + await navigator.clipboard.writeText(text) + } catch { + // Denied permissions / iframe policy. + } return } const exec = typeof document.execCommand === 'function' diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 9b7116462f..9c42e69b59 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -61,3 +61,12 @@ line-height: 24px; color: var(--dsw-alias-label-tertiary); } + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 2503a6e71b..616eee5943 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -19,10 +19,21 @@ function leadingFor(state: ToolRowState) { } } +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + /** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) { const model = toolRowModel(toolName, block) const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) + const status = stateStatus(model.state) return (
    {leadingFor(model.state)} + {status !== null && {status}} {isChild && scoped} {model.title} diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 084f73b4b7..18ac9a2891 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -113,9 +113,11 @@ describe('tails', () => { const errorView = render() expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() + expect(errorView.getByText('失败')).toBeTruthy() errorView.unmount() const stoppedView = render() expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull() + expect(stoppedView.getByText('已停止')).toBeTruthy() }) }) diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 33bcf7b80c..151af94e1c 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -18,16 +18,22 @@ export interface CodeBlockProps { className?: string | undefined } -async function writeClipboard(text: string): Promise { +/** @returns true only when the host accepted the write. */ +async function writeClipboard(text: string): Promise { if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text) - return + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // Denied permissions / iframe policy — do not claim success. + return false + } } // jsdom and older hosts: best-effort execCommand path when present. const exec = typeof document.execCommand === 'function' ? document.execCommand.bind(document) : undefined - if (exec === undefined) return + if (exec === undefined) return false const el = document.createElement('textarea') el.value = text el.setAttribute('readonly', '') @@ -36,12 +42,12 @@ async function writeClipboard(text: string): Promise { document.body.appendChild(el) el.select() try { - exec('copy') + return exec('copy') } catch { - // Clipboard unavailable (sandboxed iframe / denied permission); UI still - // flips to the ok label so the gesture is acknowledged. + return false + } finally { + el.remove() } - el.remove() } export function CodeBlock({ code, lang, className }: CodeBlockProps) { @@ -55,9 +61,11 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) { /* v8 ignore next -- both arms always mount a
    ; trimmed is the
            typed fallback if the DOM shape ever diverges. */
         const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
    -    void writeClipboard(text)
    -    setCopied(true)
    -    window.setTimeout(() => setCopied(false), 1000)
    +    void writeClipboard(text).then((ok) => {
    +      if (!ok) return
    +      setCopied(true)
    +      window.setTimeout(() => setCopied(false), 1000)
    +    })
       }, [copied, trimmed])
     
       const body = html === undefined
    diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx
    index b6fdc866f9..47b0ad24fb 100644
    --- a/packages/client/ui-primitives/tests/code-block.spec.tsx
    +++ b/packages/client/ui-primitives/tests/code-block.spec.tsx
    @@ -6,7 +6,7 @@
     // alongside the rest of the markdown family.
     
     import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
    -import { cleanup, fireEvent, render, screen } from '@testing-library/react'
    +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
     import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
     import { highlightToHtml } from '../src/markdown/highlight.ts'
     
    @@ -65,6 +65,10 @@ describe('CodeBlock', () => {
         expect(screen.getByText('ts')).toBeTruthy()
         fireEvent.click(screen.getByRole('button', { name: '复制' }))
         expect(writeText).toHaveBeenCalledWith('const a = 1')
    +    // Flush the clipboard promise under fake timers before asserting the label.
    +    await act(async () => {
    +      await Promise.resolve()
    +    })
         expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
         // While the ok label is showing, further clicks are no-ops.
         fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
    @@ -73,7 +77,22 @@ describe('CodeBlock', () => {
         expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
       })
     
    -  it('falls back to execCommand when clipboard.writeText is unavailable', () => {
    +  it('does not claim success when clipboard.writeText rejects', async () => {
    +    const writeText = vi.fn().mockRejectedValue(new Error('denied'))
    +    Object.defineProperty(navigator, 'clipboard', {
    +      configurable: true,
    +      value: { writeText },
    +    })
    +    render()
    +    fireEvent.click(screen.getByRole('button', { name: '复制' }))
    +    await act(async () => {
    +      await Promise.resolve()
    +    })
    +    expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
    +    expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
    +  })
    +
    +  it('falls back to execCommand when clipboard.writeText is unavailable', async () => {
         Object.defineProperty(navigator, 'clipboard', {
           configurable: true,
           value: undefined,
    @@ -86,9 +105,10 @@ describe('CodeBlock', () => {
         render()
         fireEvent.click(screen.getByRole('button', { name: '复制' }))
         expect(exec).toHaveBeenCalledWith('copy')
    +    expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
       })
     
    -  it('still acknowledges copy when execCommand throws', () => {
    +  it('does not claim success when execCommand throws or is absent', async () => {
         Object.defineProperty(navigator, 'clipboard', {
           configurable: true,
           value: undefined,
    @@ -99,22 +119,20 @@ describe('CodeBlock', () => {
             throw new Error('denied')
           },
         })
    -    render()
    -    fireEvent.click(screen.getByRole('button', { name: '复制' }))
    -    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
    -  })
    +    const denied = render()
    +    fireEvent.click(denied.getByRole('button', { name: '复制' }))
    +    await Promise.resolve()
    +    expect(denied.getByRole('button', { name: '复制' })).toBeTruthy()
    +    denied.unmount()
     
    -  it('acknowledges copy when neither clipboard API nor execCommand exists', () => {
    -    Object.defineProperty(navigator, 'clipboard', {
    -      configurable: true,
    -      value: undefined,
    -    })
         Object.defineProperty(document, 'execCommand', {
           configurable: true,
           value: undefined,
         })
    -    render()
    -    fireEvent.click(screen.getByRole('button', { name: '复制' }))
    -    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
    +    const absent = render()
    +    fireEvent.click(absent.getByRole('button', { name: '复制' }))
    +    await Promise.resolve()
    +    expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
    +    expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
       })
     })
    
    From cff614d37df01efe249bcc4d4bb94d3eb410443a Mon Sep 17 00:00:00 2001
    From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
    Date: Mon, 27 Jul 2026 13:17:12 +0800
    Subject: [PATCH 28/56] ci: run the pull-request Windows blocking gates under
     Wine on hosted Linux
    
    The required windows job moves from windows-2025 to ubuntu-latest, running
    checksum-verified Windows Node under Wine at Linux-job wall clock (2m46s
    warm vs 7-9min); master's serial-windows native-kernel reference is
    untouched, and a new master-only wine-apt-cache job seeds the apt cache
    every pull request restores. The experiment workflow folds into ci.yml,
    the Agent Note moves to implemented with measured results, and the two CI
    topology notes update to the shipped facts.
    ---
     ...rial-cross-platform-ci-reference.i18n.yaml |   6 +-
     ...7-21-serial-cross-platform-ci-reference.md |   2 +-
     ...1-serial-cross-platform-ci-reference.zh.md |   2 +-
     ...ortable-required-pull-request-ci.i18n.yaml |   6 +-
     ...07-23-portable-required-pull-request-ci.md |   6 +-
     ...23-portable-required-pull-request-ci.zh.md |   6 +-
     ...27-wine-windows-gates-experiment.i18n.yaml |   6 +
     ...026-07-27-wine-windows-gates-experiment.md |  45 ++++
     ...-07-27-wine-windows-gates-experiment.zh.md |  45 ++++
     ...27-wine-windows-gates-experiment.i18n.yaml |   6 -
     ...026-07-27-wine-windows-gates-experiment.md |  51 ----
     ...-07-27-wine-windows-gates-experiment.zh.md |  51 ----
     .github/AGENTS.md                             |   2 +-
     .github/workflows/ci.yml                      | 227 +++++++++++++++--
     .github/workflows/exp-wine-windows.yml        | 230 ------------------
     15 files changed, 316 insertions(+), 375 deletions(-)
     create mode 100644 .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
     create mode 100644 .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
     create mode 100644 .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md
     delete mode 100644 .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
     delete mode 100644 .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md
     delete mode 100644 .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md
     delete mode 100644 .github/workflows/exp-wine-windows.yml
    
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    index 17edb300cc..553e656805 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write
    -2026-07-21-serial-cross-platform-ci-reference.md: 5433d2c51831ce61d06a16ee0b0ed982911f9218
    -2026-07-21-serial-cross-platform-ci-reference.zh.md: 041d53d13e14354c995e4b65defce94a97646b0a
    +#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    +2026-07-21-serial-cross-platform-ci-reference.md: 5eac1bc1c47c7309942b5615bc98a7fed893f346
    +2026-07-21-serial-cross-platform-ci-reference.zh.md: 35fb761023fe7be081bf7d9591a53ed98b6e3abc
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    index 5433d2c518..5eac1bc1c4 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    @@ -24,7 +24,7 @@ The macOS reference runs the ordinary Vitest project in forked processes. Node 2
     
     Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
     
    -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
    +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
     
     ## Alternatives considered
     
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    index 041d53d13e..35fb761023 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    @@ -24,7 +24,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上
     
     master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
     
    -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
    +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
     
     ## 曾考虑的替代方案
     
    diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
    index 05147cd54a..66131cfe0c 100644
    --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write
    -2026-07-23-portable-required-pull-request-ci.md: d1002c7d9db7cd8bbed3bdfda8a773a4b124bf16
    -2026-07-23-portable-required-pull-request-ci.zh.md: fedfc6b9c982ace5ece430c52db23c22ec5119d4
    +#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    +2026-07-23-portable-required-pull-request-ci.md: 1a6939e8386e381cba114a7be71993a644457a45
    +2026-07-23-portable-required-pull-request-ci.zh.md: cf0af769f9e740a2c9285caf4be05023371578d9
    diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    index d1002c7d9d..1a6939e838 100644
    --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    @@ -12,9 +12,9 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei
     
     ## Decision
     
    -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs on standard `windows-2025` with single-worker bounds, keeping the complete Windows contract independent of enterprise Windows allocation. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
    +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)), keeping the pull-request Windows contract independent of any Windows runner allocation; the complete native-kernel Windows inventory lives in the master serial reference. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
     
    -The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
    +The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
     
     The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix.
     
    @@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md)
     
     ## Consequences
     
    -Ordinary pull requests spend enterprise capacity on the Linux critical path while standard Windows trades longer runtime for independent allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
    +Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine-hosted Windows job keeps its verdict on standard Linux allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
     
     Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work.
    diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
    index fedfc6b9c9..cf0af769f9 100644
    --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
    @@ -12,9 +12,9 @@ Status: implemented
     
     ## 决策
     
    -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `windows-2025` 上运行,并采用单工作线程上限,使完整的 Windows 契约不依赖企业级 Windows 运行器分配。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
    +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node 以覆盖阻断表面([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)),使拉取请求的 Windows 契约不依赖任何 Windows 运行器分配;完整的原生内核 Windows 清单归 master 串行参考流程所有。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
     
    -两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。
    +两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。
     
     当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。
     
    @@ -30,6 +30,6 @@ Status: implemented
     
     ## 后果
     
    -普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而标准托管 Windows 作业则以更长的运行时间换取不依赖企业池的运行器分配。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。
    +普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 托管的 Windows 作业让其判定保持在标准 Linux 运行器分配上。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。
     
     企业级运行器分配能力下降时,标准兼容性作业和必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。
    diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
    new file mode 100644
    index 0000000000..8b8b736a99
    --- /dev/null
    +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
    @@ -0,0 +1,6 @@
    +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
    +# side as of the last confirmed-consistent state. Both languages carry equal authority;
    +# after editing either side, bring the other along and re-record with:
    +#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
    +2026-07-27-wine-windows-gates-experiment.md: aab8aecdfca06c1f15641044a071015f543a84b6
    +2026-07-27-wine-windows-gates-experiment.zh.md: 5239b185e1e0c63aa626ee3f20f3f298c0c8579d
    diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
    new file mode 100644
    index 0000000000..aab8aecdfc
    --- /dev/null
    +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
    @@ -0,0 +1,45 @@
    +# Agent Note: Wine-run Windows blocking gates on Linux runners
    +
    +Status: implemented
    +
    +English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md)
    +
    +## Problem
    +
    +The pull-request Windows lane exists to prove the two blocking win32 surfaces — the workspace build and the production site — and it ran on hosted `windows-2025`, the slowest job in the required matrix: 7–9 minutes against 1.5–2.5 for the Linux jobs, so the Windows VM's boot, setup, and filesystem costs dominated every pull request's critical path.
    +
    +The question the experiment answered: can a plain Linux runner produce an equivalent win32 signal for the blocking surfaces at Linux wall clock, so no Windows VM sits on the pull-request path at all?
    +
    +## Decision
    +
    +The required pull-request `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) (`windows node 24 / wine blocking`) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a checksum-verified win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. The master `serial-windows` job is untouched: the complete native-kernel inventory, including the observational portability gates this lane does not run, still executes on real `windows-2025` on every master push.
    +
    +Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: the independent prototype in [PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; #689's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here).
    +
    +The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope.
    +
    +Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate).
    +
    +## Measured results
    +
    +Measured on 2026-07-27, warm caches, pull-request trigger, standard 2-core `ubuntu-latest`: 2m46s end-to-end — setup and cache restores ~17s, concurrent install+provision 33s, concurrent gates 110s — against 1.5–2.5 minutes for the Linux CI jobs and 7–9 minutes for the replaced `windows-2025` job. A cold-cache run pays roughly one extra minute. An 8-core benchmark leg was defined during the experiment but never left the restricted `dsh-ubuntu-*` pool's queue; the standard-runner number met the target, so no larger box is used.
    +
    +## Alternatives considered
    +
    +**Keep the hosted `windows-2025` pull-request job (status quo).** Nothing wrong with its signal, only its latency: 7–9 minutes for two build commands, the slowest required job in the matrix. It survives as the master serial reference, where completeness matters more than latency.
    +
    +**A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs (40m19s measured end-to-end on the sibling experiment branch `exp/kvm-windows-ci`). Promotable only with disk-image caching that pressures the Actions cache budget.
    +
    +**Windows pnpm performing the install under Wine ([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689)).** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict.
    +
    +**Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`; complementary to, not competitive with, this lane.
    +
    +**Windows containers.** Not possible: Windows containers require a Windows host kernel; a hosted Linux runner cannot run them.
    +
    +**Dropping the Windows lane.** Rejected — win32 is a first-class product target: the koffi-backed DACL and durable-namespace modules, ConPTY-based PTY sessions, and Windows path policy all ship in `packages/`.
    +
    +## Consequences
    +
    +Every pull request's Windows verdict now arrives in Linux-job time on free standard capacity, and no Windows VM allocation sits on the pull-request critical path; `all checks passed` consumes the same `windows` job id it always did.
    +
    +What the trade costs: Wine reimplements Win32 over a case-sensitive ext4 — NTFS case-insensitivity, real DACLs, ConPTY, and crash-durability semantics are not proved here, and the observational portability inventory (duplication, publint, node-next types, built-package invariants on win32) no longer runs on pull requests at all. The master `serial-windows` reference owns all of that: a Wine-green pull request can still fail the native-kernel master run, and that failure mode is accepted as post-merge. The lane also inherits Wine-specific divergences as permanent job structure — file-routed stdio, the host-side `vue` link, the hoisted layout — so a future toolchain change that depends on isolated-layout semantics or in-process symlink creation will surface here first as a Wine failure rather than a product failure, and triage must classify it as such. If Wine reds ever recur without product cause, the recorded fallback is reverting the `windows` job to the pre-Wine `windows-2025` definition preserved in git history.
    diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md
    new file mode 100644
    index 0000000000..5239b185e1
    --- /dev/null
    +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md
    @@ -0,0 +1,45 @@
    +# Agent Note: 在 Linux runner 上用 Wine 运行 Windows 阻断门禁
    +
    +Status: implemented
    +
    +[English](2026-07-27-wine-windows-gates-experiment.md) | 中文
    +
    +## 问题
    +
    +Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表面——workspace 构建与生产站点——它此前运行在托管 `windows-2025` 上,是必需矩阵中最慢的作业:7–9 分钟,对照 Linux 作业的 1.5–2.5 分钟,因此 Windows VM 的启动、准备与文件系统开销主导了每个 pull request 的关键路径。
    +
    +实验回答的问题是:一台普通 Linux runner 能否以 Linux 墙钟为阻断表面产出等效的 win32 信号,让 pull request 路径上完全没有 Windows VM?
    +
    +## 决策
    +
    +[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。
    +
    +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其"Windows pnpm 安装依赖树"的目标(安装契约在此仍由 Linux 侧验证)。
    +
    +该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。
    +
    +四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。
    +
    +## 实测结果
    +
    +2026-07-27 实测,热缓存,pull request 触发,标准 2 核 `ubuntu-latest`:端到端 2 分 46 秒——准备与缓存恢复约 17 秒,并发安装+供给 33 秒,并发门禁 110 秒——对照 Linux CI 作业的 1.5–2.5 分钟与被替换的 `windows-2025` 作业的 7–9 分钟。冷缓存约多付一分钟。实验期间定义过 8 核基准腿,但它从未离开受限 `dsh-ubuntu-*` 池的队列;标准 runner 的数字已达标,故不使用更大的机器。
    +
    +## 考虑过的替代方案
    +
    +**保留托管 `windows-2025` 的 pull request 作业(现状)。** 其信号没有问题,问题只在延迟:为两条构建命令花 7–9 分钟,是必需矩阵中最慢的作业。它作为 master 串行参照存续——在那里完整性比延迟更重要。
    +
    +**在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可晋升。
    +
    +**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道用掉这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。
    +
    +**Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 破坏类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。
    +
    +**Windows 容器。** 不可行:Windows 容器要求 Windows 宿主内核;托管 Linux runner 无法运行。
    +
    +**砍掉 Windows 通道。** 已否决——win32 是一等产品目标:基于 koffi 的 DACL 与持久命名空间模块、基于 ConPTY 的 PTY 会话、以及 Windows 路径策略都随 `packages/` 交付。
    +
    +## 结果
    +
    +每个 pull request 的 Windows 裁决现在以 Linux 作业的时间在免费标准容量上到达,pull request 关键路径上不再有任何 Windows VM 分配;`all checks passed` 消费的仍是原来的 `windows` 作业 id。
    +
    +这笔交易的代价:Wine 在大小写敏感的 ext4 之上重实现 Win32——NTFS 大小写不敏感、真实 DACL、ConPTY 与崩溃持久性语义在此都未被证明,且观察性可移植性清单(duplication、publint、node-next 类型、win32 上的构建包不变量)完全不再于 pull request 上运行。master 的 `serial-windows` 参照拥有这一切:Wine 绿灯的 pull request 仍可能在原生内核的 master 运行上失败,该失败模式被接受为合并后处理。该通道还把 Wine 特有的分歧继承为永久的作业结构——文件中转的 stdio、宿主侧的 `vue` 链接、hoisted 布局——因此未来依赖 isolated 布局语义或进程内符号链接创建的工具链变更会先在这里以 Wine 失败而非产品失败的形式浮现,分诊必须如此归类。若 Wine 红灯在无产品原因的情况下反复出现,记录在案的退路是把 `windows` 作业还原为 git 历史中保存的 Wine 之前的 `windows-2025` 定义。
    diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
    deleted file mode 100644
    index c39841966d..0000000000
    --- a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
    +++ /dev/null
    @@ -1,6 +0,0 @@
    -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
    -# side as of the last confirmed-consistent state. Both languages carry equal authority;
    -# after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md
    -2026-07-27-wine-windows-gates-experiment.md: 47a37ddb48f4321f916c7f7a0cb96ae80b133103
    -2026-07-27-wine-windows-gates-experiment.zh.md: 3a912861110a06b39bfb2c37395fc6a061bdfbe6
    diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md
    deleted file mode 100644
    index 47a37ddb48..0000000000
    --- a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md
    +++ /dev/null
    @@ -1,51 +0,0 @@
    -# Agent Note: Wine-run Windows blocking gates on Linux runners
    -
    -Status: proposed
    -
    -English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md)
    -
    -## Problem
    -
    -The pull-request Windows lane exists to prove the two blocking win32 surfaces — the workspace build and the production site — plus an observational portability inventory, and it runs on a dedicated paid Windows larger-runner pool; the master serial reference adds a second hosted Windows job. That pool is the only reason a Windows VM exists anywhere in this pipeline, and its provisioning, pricing, and slow setup dominate the lane's cost.
    -
    -The open question: can a plain Linux runner produce an equivalent win32 signal for the blocking surfaces, so the dedicated Windows pool can shrink to a master-only reference or disappear from the pull-request path entirely?
    -
    -## Proposal
    -
    -[exp-wine-windows.yml](../../../../.github/workflows/exp-wine-windows.yml) (self-path-filtered, plus manual dispatch) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a checksum-verified win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute.
    -
    -Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: the independent prototype in [PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; #689's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here).
    -
    -The lane targets the wall clock of the Linux CI jobs (about two minutes), from four levers: the master-refreshed pnpm store cache (restore-only, same key as ci.yml), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image so Wine's package downloads are paid once per image version.
    -
    -Measured on 2026-07-27: 2m46s end-to-end on a warm-cache pull-request run (setup and cache restores ~17s, concurrent install+provision 33s, concurrent gates 110s), against 1.5–2.5 minutes for the Linux CI jobs and 7–9 minutes for the paid Windows lane; a cold-cache run pays roughly one extra minute. The 8-core benchmark leg never left the queue — the restricted `dsh-ubuntu-*` pools were also observed queueing indefinitely from the sibling KVM experiment — so the standard-runner number stands as the result, and no larger box is needed to hit the target.
    -
    -This is deliberately a fidelity probe, not a drop-in replacement: Wine reimplements the Win32 API over a case-sensitive ext4 (NTFS case-insensitivity is not emulated by default), provides no ConPTY, and substitutes its own security-descriptor and `MoveFileExW` semantics — exactly the surfaces the repo's `win32.ts` modules and PTY backend care about. The experiment measures which blocking gates pass, which fail for Wine reasons rather than product reasons, and the wall-clock cost relative to the recorded Windows benchmark lanes.
    -
    -Promotion, if the verdict is positive: fold the Wine lane in as the pull-request Windows signal for blocking gates and demote the real-Windows pool to the master serial reference; otherwise record the failure class here and keep the pool.
    -
    -## Alternatives considered
    -
    -**Keep the dedicated Windows pool (status quo).** It is the baseline being priced; nothing is wrong with its signal, only with paying for a Windows VM pool whose blocking surface is two build commands.
    -
    -**A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs. Explored as the sibling experiment branch `exp/kvm-windows-ci`; the two experiments price fidelity against latency.
    -
    -**Windows pnpm performing the install under Wine ([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689)).** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict.
    -
    -**Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`.
    -
    -**Windows containers.** Not possible: Windows containers require a Windows host kernel; a hosted Linux runner cannot run them.
    -
    -**Dropping the Windows lane.** Rejected — win32 is a first-class product target: the koffi-backed DACL and durable-namespace modules, ConPTY-based PTY sessions, and Windows path policy all ship in `packages/`.
    -
    -## Acceptance criteria
    -
    -- The workflow completes on `ubuntu-latest` with an independent pass/fail verdict per blocking surface (build, production site) and a recorded wall-clock comparison against both the paid Windows lane and the Linux CI jobs.
    -- End-to-end wall clock lands in the same band as the Linux CI jobs (minutes, not tens of minutes), demonstrating the pool-replacement case on cost as well as signal.
    -- A decision is recorded here: promote the lane, keep it as a non-blocking canary, or reject it with the observed failure class.
    -
    -## Risks
    -
    -- False greens: Wine's case-sensitive filesystem and permissive path handling can pass code that breaks on real NTFS, so this lane can complement but never fully replace a real-kernel check for release qualification.
    -- False reds: missing or stubbed Win32 APIs under Wine fail gates for non-product reasons, and each such failure costs triage time to classify.
    -- Throughput: Wine's syscall translation on the 2-core standard runner may push the blocking gates past the paid Windows lane's wall clock, erasing the cost argument; the run records the numbers either way.
    diff --git a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md
    deleted file mode 100644
    index 3a91286111..0000000000
    --- a/.agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.zh.md
    +++ /dev/null
    @@ -1,51 +0,0 @@
    -# Agent Note: 在 Linux runner 上用 Wine 运行 Windows 阻断门禁
    -
    -Status: proposed
    -
    -[English](2026-07-27-wine-windows-gates-experiment.md) | 中文
    -
    -## 问题
    -
    -Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表面——workspace 构建与生产站点——外加一份观察性可移植性清单,它运行在一个专用的付费 Windows larger-runner 池上;master 串行参照又增加一个托管 Windows 作业。该池是这条流水线中唯一需要 Windows VM 的理由,而其供给、计价与缓慢的准备阶段主导了该通道的成本。
    -
    -悬而未决的问题是:一台普通 Linux runner 能否为阻断表面产出等效的 win32 信号,让专用 Windows 池收缩为仅 master 的参照、甚至完全退出 pull request 路径?
    -
    -## 提案
    -
    -[exp-wine-windows.yml](../../../../.github/workflows/exp-wine-windows.yml)(自身路径过滤,外加手动触发)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。
    -
    -依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其"Windows pnpm 安装依赖树"的目标(安装契约在此仍由 Linux 侧验证)。
    -
    -该通道以 Linux CI 作业的墙钟(约两分钟)为目标,靠四个杠杆:master 刷新的 pnpm store 缓存(只恢复,与 ci.yml 同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,使 Wine 的包下载每个镜像版本只付一次。
    -
    -2026-07-27 实测:热缓存 pull request 运行端到端 2 分 46 秒(准备与缓存恢复约 17 秒,并发安装+供给 33 秒,并发门禁 110 秒),对照 Linux CI 作业的 1.5–2.5 分钟与付费 Windows 通道的 7–9 分钟;冷缓存约多付一分钟。8 核基准腿从未离开队列——受限的 `dsh-ubuntu-*` 池在兄弟 KVM 实验中也被观察到无限排队——因此标准 runner 的数字即为结果,达标不需要更大的机器。
    -
    -这刻意是一次保真度探针,而非直接替换:Wine 在大小写敏感的 ext4 之上重实现 Win32 API(默认不模拟 NTFS 的大小写不敏感)、不提供 ConPTY、并用自己的安全描述符与 `MoveFileExW` 语义替代——恰是本仓库 `win32.ts` 模块与 PTY 后端关心的表面。实验度量哪些阻断门禁通过、哪些因 Wine 原因而非产品原因失败,以及相对已记录 Windows 基准通道的墙钟成本。
    -
    -若结论为正则晋升:把 Wine 通道并入为 pull request 的阻断门禁 Windows 信号,将真实 Windows 池降级为 master 串行参照;否则在此记录失败类别并保留该池。
    -
    -## 考虑过的替代方案
    -
    -**保留专用 Windows 池(现状)。** 它正是被计价的基线;其信号没有问题,问题只在于为一个阻断表面仅是两条构建命令的 Windows VM 池付费。
    -
    -**在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装。作为兄弟实验分支 `exp/kvm-windows-ci` 探索;两个实验共同为保真度与延迟定价。
    -
    -**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道用掉这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。
    -
    -**Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 破坏类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索。
    -
    -**Windows 容器。** 不可行:Windows 容器要求 Windows 宿主内核;托管 Linux runner 无法运行。
    -
    -**砍掉 Windows 通道。** 已否决——win32 是一等产品目标:基于 koffi 的 DACL 与持久命名空间模块、基于 ConPTY 的 PTY 会话、以及 Windows 路径策略都随 `packages/` 交付。
    -
    -## 验收标准
    -
    -- 该 workflow 在 `ubuntu-latest` 上完成,对每个阻断表面(构建、生产站点)给出独立的通过/失败裁决,并记录与付费 Windows 通道及 Linux CI 作业两者的墙钟对比。
    -- 端到端墙钟落在 Linux CI 作业的同一档位(分钟级,而非数十分钟),从成本与信号两方面共同论证替换池的理由。
    -- 在此记录一项决定:晋升该通道、保留为非阻断金丝雀、或以观察到的失败类别否决。
    -
    -## 风险
    -
    -- 假绿:Wine 的大小写敏感文件系统与宽松路径处理可能放过在真实 NTFS 上会坏的代码,因此该通道可以补充、但永远无法完全替代发布资格所需的真实内核检查。
    -- 假红:Wine 下缺失或桩化的 Win32 API 会因非产品原因让门禁失败,每次此类失败都要花分诊时间归类。
    -- 吞吐:Wine 的系统调用翻译在 2 核标准 runner 上可能让阻断门禁的墙钟超过付费 Windows 通道,抹掉成本论点;无论结果如何,运行都会记录数字。
    diff --git a/.github/AGENTS.md b/.github/AGENTS.md
    index 5f03c8617d..ff4fd4e6b2 100644
    --- a/.github/AGENTS.md
    +++ b/.github/AGENTS.md
    @@ -1,3 +1,3 @@
     # AGENTS.md — GitHub Actions
     
    -Run Windows jobs under native `pwsh`.
    +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is not one of them: it runs Windows Node under Wine on hosted Linux, so its steps are bash — see the [Wine lane Agent Note](../.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md).
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index f02d30a563..94c97fd0be 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -281,41 +281,224 @@ jobs:
           - name: Run complete keyless Python suite
             run: uv run --python 3.10 --group test --project python/sdk pytest
     
    -  # One standard Windows box shares setup across the required build/site checks
    -  # and the observational portability inventory. Serial worker bounds keep this
    -  # recovery path portable; Linux owns duplicate lint, coverage, and snapshots.
    +  # The required pull-request Windows signal: the two blocking win32 surfaces
    +  # (workspace build, production site) execute with real, checksum-verified
    +  # Windows Node under Wine on standard hosted Linux. The master
    +  # serial-windows job below keeps the complete native-kernel inventory —
    +  # including the observational portability gates this lane does not run —
    +  # on real windows-2025. Direct tool entrypoints stand in for pnpm's cmd
    +  # shims, which a Linux-side install does not create; layout, fidelity
    +  # limits, and measured timings live in
    +  # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
       windows:
         if: github.event_name == 'pull_request'
    -    runs-on: windows-2025
    -    name: windows node 24 / complete
    +    runs-on: ubuntu-latest
    +    name: windows node 24 / wine blocking
    +    timeout-minutes: 15
         env:
    -      DSH_COVERAGE_MAX_WORKERS: '1'
    -      DSH_GATE_CONCURRENCY: '1'
    -      DSH_PUBLINT_CONCURRENCY: '1'
    +      WINEDEBUG: '-all'
    +      WINEARCH: win64
    +      # Skip Wine Mono / Gecko installers: Node needs neither.
    +      WINEDLLOVERRIDES: 'mscoree,mshtml='
         steps:
           - uses: actions/checkout@v6
    -
    -      - name: Enable Developer Mode (symlink support)
    -        shell: pwsh
    -        run: >-
    -          reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
    -          /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
    +        with:
    +          persist-credentials: false
     
           - uses: actions/setup-node@v6
             with:
               node-version: ${{ env.PRIMARY_NODE_VERSION }}
     
    -      # Extracting the many-file pnpm store cache is slower than a clean install,
    -      # and saving it adds more latency after gates.
    -      - name: Enable corepack and install (immutable)
    -        shell: pwsh
    +      - uses: actions/cache/restore@v4
    +        with:
    +          path: /home/runner/.local/share/pnpm/store/v11
    +          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
    +          restore-keys: |
    +            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
    +
    +      # Master's wine-apt-cache job seeds the default-branch scope every pull
    +      # request can read; a save from this job only reaches reruns of the
    +      # same merge ref.
    +      - name: Compose Wine apt cache key
    +        id: wine-cache-key
    +        run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
    +
    +      - uses: actions/cache@v4
    +        with:
    +          path: ~/wine-debs
    +          key: ${{ steps.wine-cache-key.outputs.key }}
    +
    +      - name: Install dependencies and provision Wine concurrently
             run: |
               corepack enable
    -          pnpm install --frozen-lockfile
     
    -      - name: Run blocking and observational Windows gates concurrently
    -        shell: pwsh
    -        run: pnpm run check:ci:windows-complete
    +          # Windows-lane install-time overrides. supportedArchitectures
    +          # additionally materializes the win32-x64 platform packages
    +          # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) the
    +          # Windows toolchain resolves at runtime; nodeLinker: hoisted lays
    +          # node_modules out flat with real files because Windows Node under
    +          # Wine does not realpath pnpm's isolated-layout symlinks. Neither
    +          # override is recorded in the lockfile, so --frozen-lockfile stays
    +          # valid. --ignore-scripts skips Linux lifecycle scripts no gate in
    +          # this lane loads; the win32 binaries ship prebuilt.
    +          cat >> pnpm-workspace.yaml <<'EOF'
    +
    +          nodeLinker: hoisted
    +          supportedArchitectures:
    +            os: [current, win32]
    +            cpu: [current, x64]
    +          EOF
    +
    +          pnpm install --frozen-lockfile --ignore-scripts &
    +          install_pid=$!
    +
    +          provision_wine() {
    +            set -euo pipefail
    +            # Wine from the apt cache when present; else download the full
    +            # dependency closure once and keep it for the next run. The
    +            # `wine` dispatcher package (not bare `wine64`) is what puts a
    +            # binary on PATH.
    +            if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then
    +              sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
    +            else
    +              sudo apt-get update
    +              sudo apt-get install -y --no-install-recommends --download-only wine
    +              mkdir -p "$HOME/wine-debs"
    +              cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true
    +              sudo apt-get install -y --no-install-recommends wine
    +            fi
    +            WINE_BIN=''
    +            for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
    +              if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi
    +            done
    +            [ -n "$WINE_BIN" ] || { echo '::error::no wine binary found after install'; exit 1; }
    +            echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV"
    +
    +            # Windows Node for the repo's primary line, checksum-verified
    +            # against the same dist directory.
    +            version=$(curl -fsSL https://nodejs.org/dist/index.json \
    +              | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version')
    +            echo "Windows Node: $version"
    +            curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \
    +              "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip"
    +            curl -fsSL "https://nodejs.org/dist/${version}/SHASUMS256.txt" \
    +              | awk -v a="node-${version}-win-x64.zip" '$2 == a { print $1 "  '"$RUNNER_TEMP"'/node-win.zip" }' \
    +              | sha256sum --check -
    +            unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win"
    +            echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV"
    +
    +            "$WINE_BIN" wineboot --init || true
    +            wineserver -w || true
    +          }
    +          provision_wine &
    +          wine_pid=$!
    +
    +          install_status=0
    +          wait "$install_pid" || install_status=$?
    +          wine_status=0
    +          wait "$wine_pid" || wine_status=$?
    +          if (( install_status != 0 )); then exit "$install_status"; fi
    +          exit "$wine_status"
    +
    +      - name: Resolve entrypoints, link vue, smoke Windows Node
    +        run: |
    +          # Node under Wine cannot attach stdio to the Actions runner's pipes
    +          # (Socket open EBADF at bootstrap), so every invocation runs through
    +          # this wrapper: stdio to a regular file, replayed after exit.
    +          cat > "$RUNNER_TEMP/wine-node.sh" <<'SH'
    +          #!/usr/bin/env bash
    +          set -u
    +          log="$1"; shift
    +          "$WINE_BIN" "$NODE_WIN" "$@" < /dev/null > "$log" 2>&1
    +          status=$?
    +          tail -n 300 "$log"
    +          exit "$status"
    +          SH
    +          chmod +x "$RUNNER_TEMP/wine-node.sh"
    +
    +          resolve() {
    +            local name="$1"; shift
    +            for p in "$@"; do
    +              if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi
    +            done
    +            echo "::error::$name not found at any of: $*"; return 1
    +          }
    +          resolve TSC_JS node_modules/typescript/bin/tsc
    +          resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs
    +          resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js
    +
    +          # VitePress links vue into the site's node_modules at build time;
    +          # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows
    +          # pre-existing Unix ones, so lay the link down host-side.
    +          if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
    +            mkdir -p website/node_modules
    +            ln -s ../../node_modules/vue website/node_modules/vue
    +          fi
    +
    +          "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
    +
    +      # The two blocking surfaces run concurrently, the same shape run-gates
    +      # gives ci-windows-blocking on native Windows: `build` = tsc -b then
    +      # tsdown, `production site` = the VitePress build. Both statuses are
    +      # captured so one failure cannot hide the other's result.
    +      - name: Run blocking Windows gates concurrently under Wine
    +        run: |
    +          build_gate() {
    +            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false || return $?
    +            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS"
    +          }
    +          site_gate() {
    +            cd website
    +            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build .
    +          }
    +          start=$SECONDS
    +          build_gate > "$RUNNER_TEMP/build-gate.out" 2>&1 &
    +          build_pid=$!
    +          site_gate > "$RUNNER_TEMP/site-gate.out" 2>&1 &
    +          site_pid=$!
    +          build_status=0
    +          wait "$build_pid" || build_status=$?
    +          site_status=0
    +          wait "$site_pid" || site_status=$?
    +          echo "== build gate (exit $build_status, $((SECONDS - start))s elapsed) =="
    +          tail -n 120 "$RUNNER_TEMP/build-gate.out"
    +          echo "== production site gate (exit $site_status, $((SECONDS - start))s elapsed) =="
    +          tail -n 120 "$RUNNER_TEMP/site-gate.out"
    +          if (( build_status != 0 )); then exit "$build_status"; fi
    +          exit "$site_status"
    +
    +      - name: Shut down wineserver
    +        if: always()
    +        run: wineserver -k 2>/dev/null || true
    +
    +  # Master seeds the Wine apt-archive cache in the default-branch scope,
    +  # which every pull request's windows job can restore; saves from
    +  # pull-request runs are scoped to their own merge ref and help nobody
    +  # else. Runs in seconds when the image version already has a cache.
    +  wine-apt-cache:
    +    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
    +    name: wine apt cache
    +    runs-on: ubuntu-latest
    +    timeout-minutes: 10
    +    steps:
    +      - name: Compose Wine apt cache key
    +        id: wine-cache-key
    +        run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
    +
    +      - uses: actions/cache@v4
    +        id: wine-cache
    +        with:
    +          path: ~/wine-debs
    +          key: ${{ steps.wine-cache-key.outputs.key }}
    +
    +      - name: Download the Wine dependency closure
    +        if: steps.wine-cache.outputs.cache-hit != 'true'
    +        run: |
    +          sudo apt-get update
    +          sudo apt-get install -y --no-install-recommends --download-only wine
    +          mkdir -p "$HOME/wine-debs"
    +          cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/"
    +          du -sh "$HOME/wine-debs"
     
       # Master pushes run only the serial reference jobs below.
       # Each host executes the complete, unsharded primary Node aggregate with one
    diff --git a/.github/workflows/exp-wine-windows.yml b/.github/workflows/exp-wine-windows.yml
    deleted file mode 100644
    index e9a79a18a7..0000000000
    --- a/.github/workflows/exp-wine-windows.yml
    +++ /dev/null
    @@ -1,230 +0,0 @@
    -# EXPERIMENT: run the blocking Windows CI gates on a Linux runner through
    -# Wine with a real Windows Node.js binary, at roughly the wall clock of the
    -# Linux CI jobs (~2 min). Speed comes from four levers: the master-refreshed
    -# pnpm store cache, provisioning Wine concurrently with the dependency
    -# install, running the two blocking surfaces concurrently (the same shape
    -# run-gates gives them on native Windows), and an apt package cache for Wine
    -# itself. Dependency provisioning happens natively on Linux with
    -# `supportedArchitectures` extended to win32-x64 so the Windows
    -# esbuild/rolldown/rollup binaries are present, and `nodeLinker: hoisted`
    -# because Windows Node under Wine does not realpath pnpm's isolated-layout
    -# Unix symlinks — the sibling prototype in PR #689 kept the isolated layout
    -# and failed on exactly that. The pnpm-run/cmd shim layer is deliberately
    -# bypassed; each gate invokes its tool's JavaScript entrypoint directly — the
    -# same commands run-gates ultimately spawns. Owning rationale and promotion
    -# criteria:
    -# .agents/notes/proposed/process/2026-07-27-wine-windows-gates-experiment.md
    -name: Experiment Wine Windows gates
    -
    -on:
    -  workflow_dispatch:
    -  pull_request:
    -    paths:
    -      - .github/workflows/exp-wine-windows.yml
    -
    -concurrency:
    -  group: ${{ github.workflow }}-${{ github.ref }}
    -  cancel-in-progress: true
    -
    -permissions:
    -  contents: read
    -
    -env:
    -  PRIMARY_NODE_VERSION: '24'
    -
    -jobs:
    -  wine-blocking-gates:
    -    name: wine / blocking windows gates (${{ matrix.runner }})
    -    # Pull requests run the free standard runner only; a manual dispatch adds
    -    # the 8-core benchmark pool for a like-for-like core-count comparison.
    -    # The larger leg stays dispatch-only because those restricted pools can
    -    # queue indefinitely (observed on the sibling KVM experiment).
    -    runs-on: ${{ matrix.runner }}
    -    strategy:
    -      fail-fast: false
    -      matrix:
    -        runner: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["ubuntu-latest", "dsh-ubuntu-24-04-8core"]' || '["ubuntu-latest"]') }}
    -    timeout-minutes: 30
    -    env:
    -      WINEDEBUG: '-all'
    -      WINEARCH: win64
    -      # Skip Wine Mono / Gecko installers: Node needs neither.
    -      WINEDLLOVERRIDES: 'mscoree,mshtml='
    -    steps:
    -      - uses: actions/checkout@v6
    -        with:
    -          persist-credentials: false
    -
    -      - uses: actions/setup-node@v6
    -        with:
    -          node-version: ${{ env.PRIMARY_NODE_VERSION }}
    -
    -      # The default-branch pnpm store cache ci.yml maintains; restore-only,
    -      # same key, so this lane rides the cache master already refreshes.
    -      - uses: actions/cache/restore@v4
    -        with:
    -          path: /home/runner/.local/share/pnpm/store/v11
    -          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
    -          restore-keys: |
    -            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
    -
    -      # Keyed on the runner image so a new image version re-downloads once.
    -      # Cache scoping: each trigger seeds its own scope (pull_request → the
    -      # PR merge ref, dispatch → the branch); only same-scope reruns hit.
    -      # Promotion to ci.yml would let master seed the shared default-branch
    -      # scope every trigger reads, as the pnpm store cache already does.
    -      - name: Compose Wine apt cache key
    -        id: wine-cache-key
    -        run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
    -
    -      - uses: actions/cache@v4
    -        with:
    -          path: ~/wine-debs
    -          key: ${{ steps.wine-cache-key.outputs.key }}
    -
    -      - name: Install dependencies and provision Wine concurrently
    -        run: |
    -          corepack enable
    -
    -          # Experiment-only install-time overrides. supportedArchitectures
    -          # additionally materializes the win32-x64 platform packages
    -          # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) the
    -          # Windows toolchain resolves at runtime; nodeLinker: hoisted lays
    -          # node_modules out flat with real files because Windows Node under
    -          # Wine does not realpath pnpm's isolated-layout symlinks (PR #689's
    -          # failure mode). Neither override is recorded in the lockfile, so
    -          # --frozen-lockfile stays valid. --ignore-scripts skips the Linux
    -          # esbuild/node-pty/lefthook lifecycle scripts: no gate in this lane
    -          # loads them, and the win32 binaries ship prebuilt in their
    -          # packages.
    -          cat >> pnpm-workspace.yaml <<'EOF'
    -
    -          nodeLinker: hoisted
    -          supportedArchitectures:
    -            os: [current, win32]
    -            cpu: [current, x64]
    -          EOF
    -
    -          pnpm install --frozen-lockfile --ignore-scripts &
    -          install_pid=$!
    -
    -          provision_wine() {
    -            set -euo pipefail
    -            # Wine from the apt cache when present; else download the full
    -            # dependency closure once and keep it for the next run. The
    -            # `wine` dispatcher package (not bare `wine64`) is what puts a
    -            # binary on PATH.
    -            if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then
    -              sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
    -            else
    -              sudo apt-get update
    -              sudo apt-get install -y --no-install-recommends --download-only wine
    -              mkdir -p "$HOME/wine-debs"
    -              cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true
    -              sudo apt-get install -y --no-install-recommends wine
    -            fi
    -            WINE_BIN=''
    -            for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
    -              if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi
    -            done
    -            [ -n "$WINE_BIN" ] || { echo '::error::no wine binary found after install'; exit 1; }
    -            echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV"
    -
    -            # Windows Node for the repo's primary line, checksum-verified
    -            # against the same dist directory (adopted from PR #689).
    -            version=$(curl -fsSL https://nodejs.org/dist/index.json \
    -              | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version')
    -            echo "Windows Node: $version"
    -            curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \
    -              "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip"
    -            curl -fsSL "https://nodejs.org/dist/${version}/SHASUMS256.txt" \
    -              | awk -v a="node-${version}-win-x64.zip" '$2 == a { print $1 "  '"$RUNNER_TEMP"'/node-win.zip" }' \
    -              | sha256sum --check -
    -            unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win"
    -            echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV"
    -
    -            "$WINE_BIN" wineboot --init || true
    -            wineserver -w || true
    -          }
    -          provision_wine &
    -          wine_pid=$!
    -
    -          install_status=0
    -          wait "$install_pid" || install_status=$?
    -          wine_status=0
    -          wait "$wine_pid" || wine_status=$?
    -          if (( install_status != 0 )); then exit "$install_status"; fi
    -          exit "$wine_status"
    -
    -      - name: Resolve entrypoints, link vue, smoke Windows Node
    -        run: |
    -          # Node under Wine cannot attach stdio to the Actions runner's pipes
    -          # (Socket open EBADF at bootstrap), so every invocation runs through
    -          # this wrapper: stdio to a regular file, replayed after exit.
    -          cat > "$RUNNER_TEMP/wine-node.sh" <<'SH'
    -          #!/usr/bin/env bash
    -          set -u
    -          log="$1"; shift
    -          "$WINE_BIN" "$NODE_WIN" "$@" < /dev/null > "$log" 2>&1
    -          status=$?
    -          tail -n 300 "$log"
    -          exit "$status"
    -          SH
    -          chmod +x "$RUNNER_TEMP/wine-node.sh"
    -
    -          resolve() {
    -            local name="$1"; shift
    -            for p in "$@"; do
    -              if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi
    -            done
    -            echo "::error::$name not found at any of: $*"; return 1
    -          }
    -          resolve TSC_JS node_modules/typescript/bin/tsc
    -          resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs
    -          resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js
    -
    -          # VitePress links vue into the site's node_modules at build time;
    -          # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows
    -          # pre-existing Unix ones, so lay the link down host-side.
    -          if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
    -            mkdir -p website/node_modules
    -            ln -s ../../node_modules/vue website/node_modules/vue
    -          fi
    -
    -          "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
    -
    -      # The two blocking surfaces run concurrently, the same shape run-gates
    -      # gives ci-windows-blocking on native Windows (DSH_GATE_CONCURRENCY):
    -      # `build` = tsc -b then tsdown, `production site` = the VitePress
    -      # build. Both statuses are captured so one failure cannot hide the
    -      # other's result.
    -      - name: Run blocking Windows gates concurrently under Wine
    -        timeout-minutes: 20
    -        run: |
    -          build_gate() {
    -            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false || return $?
    -            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS"
    -          }
    -          site_gate() {
    -            cd website
    -            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build .
    -          }
    -          start=$SECONDS
    -          build_gate > "$RUNNER_TEMP/build-gate.out" 2>&1 &
    -          build_pid=$!
    -          site_gate > "$RUNNER_TEMP/site-gate.out" 2>&1 &
    -          site_pid=$!
    -          build_status=0
    -          wait "$build_pid" || build_status=$?
    -          site_status=0
    -          wait "$site_pid" || site_status=$?
    -          echo "== build gate (exit $build_status, $((SECONDS - start))s elapsed) =="
    -          tail -n 120 "$RUNNER_TEMP/build-gate.out"
    -          echo "== production site gate (exit $site_status, $((SECONDS - start))s elapsed) =="
    -          tail -n 120 "$RUNNER_TEMP/site-gate.out"
    -          if (( build_status != 0 )); then exit "$build_status"; fi
    -          exit "$site_status"
    -
    -      - name: Shut down wineserver
    -        if: always()
    -        run: wineserver -k 2>/dev/null || true
    
    From 7ca13198de952ea4d200633ef17fe5232311bfed Mon Sep 17 00:00:00 2001
    From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
    Date: Mon, 27 Jul 2026 14:05:32 +0800
    Subject: [PATCH 29/56] ci: keep required status aggregator portable
    
    ---
     ...able-required-status-aggregation.i18n.yaml |  6 ++++
     ...27-portable-required-status-aggregation.md | 35 +++++++++++++++++++
     ...portable-required-status-aggregation.zh.md | 35 +++++++++++++++++++
     .github/workflows/ci.yml                      |  4 +--
     4 files changed, 78 insertions(+), 2 deletions(-)
     create mode 100644 .agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.i18n.yaml
     create mode 100644 .agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.md
     create mode 100644 .agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.zh.md
    
    diff --git a/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.i18n.yaml
    new file mode 100644
    index 0000000000..a029a82389
    --- /dev/null
    +++ b/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.i18n.yaml
    @@ -0,0 +1,6 @@
    +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
    +# side as of the last confirmed-consistent state. Both languages carry equal authority;
    +# after editing either side, bring the other along and re-record with:
    +#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.md
    +2026-07-27-portable-required-status-aggregation.md: 081d841cfb939c97f189c46fc0985f9ff2d1987d
    +2026-07-27-portable-required-status-aggregation.zh.md: 896e9500d2ad6e2e0ef6c6cc7a37373c6d28b303
    diff --git a/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.md b/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.md
    new file mode 100644
    index 0000000000..081d841cfb
    --- /dev/null
    +++ b/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.md
    @@ -0,0 +1,35 @@
    +# Agent Note: Portable required-status aggregation
    +
    +Status: implemented
    +
    +English | [中文](2026-07-27-portable-required-status-aggregation.zh.md)
    +
    +## Problem
    +
    +Branch protection consumes one stable `all checks passed` job instead of tracking the changing names of matrix legs and execution lanes. This job performs no repository work: after its blocking dependencies finish, it only reduces their results into the required verdict.
    +
    +Assigning that bookkeeping job to a custom runner pool adds an external allocation dependency without using the pool's additional CPU or memory. A provisioning failure can therefore leave the final required status queued even after every substantive check has produced its evidence.
    +
    +## Decision
    +
    +The `all-checks-passed` job in [CI](../../../../.github/workflows/ci.yml) runs on standard GitHub-hosted `ubuntu-latest`. It keeps every blocking job in `needs`, retains its load-bearing `if: always()` condition, fails when any dependency is failed, cancelled, or skipped, and succeeds only when every dependency succeeds. It performs no checkout, toolchain setup, dependency installation, or repository gate.
    +
    +The aggregate depends only on production standard-hosted capacity; it does not use organization-defined, enterprise-defined, or self-hosted labels. Substantive jobs choose their own runner topology independently. Moving this verdict does not change their commands, weaken their evidence, or make an unresolved dependency pass: the aggregate waits for unfinished dependencies and fails on non-success terminal results.
    +
    +This decision supersedes only the aggregate-placement clause in the [portable pull-request CI recovery boundary](2026-07-23-portable-required-pull-request-ci.md), which continues to own the substantive jobs' recovery topology. The final bookkeeping status remains separately owned so runner-topology changes and branch-protection aggregation can evolve independently.
    +
    +## Alternatives considered
    +
    +**Run the aggregate beside substantive jobs on a custom enterprise pool.** This avoids one short standard-hosted allocation, but gives the bookkeeping job a provisioning failure mode without using the larger machine's capacity.
    +
    +**Use a standby self-hosted runner.** This replaces one external readiness dependency with another and makes a required verdict depend on a separately operated machine. Managed standard-hosted capacity is the production path for this bookkeeping work.
    +
    +**Require every substantive job directly in branch protection.** This removes the aggregate allocation, but couples repository settings to matrix and lane names that change as the CI topology evolves.
    +
    +**Treat missing or non-success dependencies as success.** This would produce a green status by discarding required evidence rather than by completing it.
    +
    +## Consequences
    +
    +Each pull request allocates one short standard-hosted job after its substantive dependencies settle. Because the job performs no checkout or setup, it adds little active runtime, but its scheduling and billing remain separate from custom pools.
    +
    +A custom-pool outage can still keep a substantive dependency queued, and the aggregate correctly waits in that case. Once the dependencies reach terminal results, the final required verdict no longer needs custom-pool or self-hosted allocation. Future changes can move substantive jobs between standard and larger runners without reintroducing that dependency into the branch-protection status.
    diff --git a/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.zh.md b/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.zh.md
    new file mode 100644
    index 0000000000..896e9500d2
    --- /dev/null
    +++ b/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.zh.md
    @@ -0,0 +1,35 @@
    +# Agent Note: 必需状态的可移植聚合
    +
    +Status: implemented
    +
    +[English](2026-07-27-portable-required-status-aggregation.md) | 中文
    +
    +## 问题
    +
    +分支保护只使用一项稳定的 `all checks passed` 作业,无需跟踪持续变化的矩阵分支名和执行通道名。该作业不执行任何仓库工作:会阻塞判定的依赖项结束后,它只将这些依赖项的结果归并为必需判定。
    +
    +将这项结果汇总作业分配给自定义运行器池,会在不使用该池额外 CPU 或内存的情况下增加一项外部运行器分配依赖。因此,即使所有实质性检查都已产出证据,预配失败仍可能让最终的必需状态持续排队。
    +
    +## 决策
    +
    +[CI](../../../../.github/workflows/ci.yml) 中的 `all-checks-passed` 作业在 GitHub 标准托管的 `ubuntu-latest` 上运行。它在 `needs` 中保留所有会阻塞判定的作业,保留承重的 `if: always()` 条件;任何依赖项失败、被取消或被跳过时,该作业都会失败,只有所有依赖项都成功时才会成功。它不执行代码检出、工具链设置、依赖安装或仓库门禁。
    +
    +聚合作业只依赖生产环境的标准托管容量;它不使用组织定义的、企业定义的或自托管的运行器标签。实质性作业各自独立选择运行器拓扑。调整这项判定作业的运行位置,不会改变实质性作业的命令、削弱其证据或使未完成的依赖项通过:聚合作业会等待尚未结束的依赖项,并在依赖项产生非成功的终态结果时失败。
    +
    +本决策仅取代[拉取请求 CI 的可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)中关于聚合作业运行位置的条款;该记录继续规定实质性作业的恢复拓扑。最终的结果汇总状态仍由本决策单独规定,使运行器拓扑变更与分支保护聚合可以独立演进。
    +
    +## 曾考虑的替代方案
    +
    +**让聚合作业与实质性作业一同在自定义企业级运行器池上运行。** 此方案可以避免一次短暂的标准托管运行器分配,但会在未使用大型机器容量的情况下,为结果汇总作业引入预配失败的故障模式。
    +
    +**使用备用自托管运行器。** 此方案只是用另一项外就绪状态依赖替换原有依赖,并使必需判定依赖一台单独运维的机器。由平台管理的标准托管容量是这项结果汇总工作的生产路径。
    +
    +**在分支保护中直接要求每项实质性作业。** 此方案不再需要为聚合作业分配运行器,但会将仓库设置与随 CI 拓扑演进而变化的矩阵分支名和通道名耦合。
    +
    +**将缺失或非成功的依赖项视为成功。** 这种做法不是通过完成相应检查来产出必需证据,而是丢弃这些证据以产出绿色状态。
    +
    +## 后果
    +
    +每个拉取请求都会在实质性依赖项的结果确定后分配一项短时运行的标准托管作业。由于该作业不执行代码检出或设置,它只增加少量活跃运行时间,但其调度和计费仍独立于自定义运行器池。
    +
    +自定义运行器池不可用仍可能让实质性依赖项持续排队,聚合作业在这种情况下会按设计等待。依赖项产生终态结果后,最终的必需判定不再需要自定义运行器池或自托管运行器分配。未来可以在标准运行器与大型运行器之间迁移实质性作业,而不会将这项依赖重新引入分支保护状态。
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index f02d30a563..413a3cb51b 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -693,8 +693,8 @@ jobs:
       # 'cancelled' and 'skipped'.
       all-checks-passed:
         name: all checks passed
    -    # The required verdict must not add a separate standard-hosted billing dependency.
    -    runs-on: dsh-enterprise-ubuntu-latest-32core-test
    +    # This bookkeeping-only verdict must not depend on custom-pool provisioning.
    +    runs-on: ubuntu-latest
         needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows]
         if: always() && github.event_name == 'pull_request'
         steps:
    
    From 761eeb7c55c3e34902c1dfa1acee33345d8202d5 Mon Sep 17 00:00:00 2001
    From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
    Date: Mon, 27 Jul 2026 14:15:46 +0800
    Subject: [PATCH 30/56] ci: restore standard runners for primary checks
    
    ---
     ...rial-cross-platform-ci-reference.i18n.yaml |  6 ++---
     ...7-21-serial-cross-platform-ci-reference.md |  6 ++---
     ...1-serial-cross-platform-ci-reference.zh.md |  6 ++---
     ...ence-based-larger-hosted-runners.i18n.yaml |  6 ++---
     ...22-evidence-based-larger-hosted-runners.md | 20 ++++++++---------
     ...evidence-based-larger-hosted-runners.zh.md | 20 ++++++++---------
     ...ortable-required-pull-request-ci.i18n.yaml |  6 ++---
     ...07-23-portable-required-pull-request-ci.md | 16 +++++++-------
     ...23-portable-required-pull-request-ci.zh.md | 16 +++++++-------
     .github/workflows/ci.yml                      | 22 +++++++++----------
     10 files changed, 62 insertions(+), 62 deletions(-)
    
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    index 17edb300cc..8cd2a0f7c8 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write
    -2026-07-21-serial-cross-platform-ci-reference.md: 5433d2c51831ce61d06a16ee0b0ed982911f9218
    -2026-07-21-serial-cross-platform-ci-reference.zh.md: 041d53d13e14354c995e4b65defce94a97646b0a
    +#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    +2026-07-21-serial-cross-platform-ci-reference.md: 220c4b2a092ec1907482edc60a12f981fdf986a4
    +2026-07-21-serial-cross-platform-ci-reference.zh.md: 70e4c40f0f64fef4b1de05a7603ece25aaf5bea2
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    index 5433d2c518..220c4b2a09 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    @@ -14,7 +14,7 @@ Reviewers also need a direct answer to a simpler question: what happens when the
     
     ## Decision
     
    -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
    +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run three primary Linux jobs, one complete Windows job, and the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
     
     Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
     
    @@ -24,7 +24,7 @@ The macOS reference runs the ordinary Vitest project in forked processes. Node 2
     
     Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
     
    -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
    +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Substantive required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
     
     ## Alternatives considered
     
    @@ -32,7 +32,7 @@ The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, a
     - **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
     - **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts.
     - **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
    -- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
    +- **Run the serial reference on larger runners** - rejected because substantive required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
     
     ## Consequences
     
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    index 041d53d13e..70e4c40f0f 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    @@ -14,7 +14,7 @@ Status: implemented
     
     ## 决策
     
    -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
    +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行 3 项 Linux 主作业、1 项完整的 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
     
     每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
     
    @@ -24,7 +24,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上
     
     master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
     
    -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
    +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求中的实质性必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
     
     ## 曾考虑的替代方案
     
    @@ -32,7 +32,7 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的
     - **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
     - **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。
     - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
    -- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。
    +- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,承担实质性检查的必需 CI 及其独立参考流程都必须仍可运行。
     
     ## 后果
     
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    index 3d8e6fc395..eeaa689fd8 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write
    -2026-07-22-evidence-based-larger-hosted-runners.md: fe11e6929545923d27fbf41f5a39f7dd2b9c3fbf
    -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 47879284532a537cbe7e78aa2c495c4ef0be26c4
    +#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    +2026-07-22-evidence-based-larger-hosted-runners.md: 8a3ca991accd5bbe71b6f92cffff4c9a420b1f25
    +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 870c4301eed4793d3ac6801c310288abf1d46c3b
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    index fe11e69295..8a3ca991ac 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    @@ -12,19 +12,19 @@ Larger runners make it possible to pay setup once and parallelize inside the rep
     
     ## Decision
     
    -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name three 32-core pools directly: Ubuntu 24.04 for exhaustive coverage, Ubuntu latest for the remaining primary Node 24 inventory, and Windows 2025 for blocking Windows contracts. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit.
    +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows as measurement infrastructure. Public IPs are disabled, and benchmark concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit.
     
    -The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work.
    +Ordinary pull requests use the standard-hosted primary path owned by the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md). `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. The [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keeps an independent complete standard-runner oracle on `master`.
     
     The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture.
     
    -Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
    +Linux primary work uses three independent standard-hosted jobs with single-worker inner bounds. Coverage runs alone, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
     
     The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking builds its complete project-reference graph once. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count.
     
     The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails.
     
    -Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim.
    +Windows shares one standard-hosted setup across the blocking build and production site plus observational built-artifact contracts, with single-worker bounds. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the critical path without adding a blocking platform claim.
     
     An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction:
     
    @@ -50,11 +50,11 @@ Inner and outer worker limits are separate controls. An [exact-head 32-worker ES
     
     The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection.
     
    -Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch.
    +Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the standard-hosted required path, while larger-runner sizes run only by manual dispatch.
     
     ## Alternatives considered
     
    -**Keep the three coarse primary Linux lanes.** The core, CPU, and production-site jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute.
    +**Restore the former core, CPU, and production-site lanes.** Those jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute.
     
     **Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises.
     
    @@ -68,7 +68,7 @@ Complete serial Linux, macOS, and Windows references run only when `master` move
     
     **Keep static gates and post-build consumers on one runner.** Reusing one workspace avoids a setup wave and artifact transfer, but build-duration variance delays every consumer and leaves their lint and snapshot tails after the static result. A run-scoped built tree preserves one exact build while independent jobs keep both complete paths within the observed target.
     
    -**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path.
    +**Use larger-runner pools as the required default.** This offers lower measured latency when allocation works, but a missing entitlement or delayed enterprise transfer leaves required jobs queued without repository diagnostics. The portable path accepts longer runtime, and manual suites preserve the performance experiment.
     
     **Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process.
     
    @@ -76,10 +76,10 @@ Complete serial Linux, macOS, and Windows references run only when `master` move
     
     ## Consequences
     
    -The required topology pays one setup wave per 32-core lane and retains no shard selectors. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful.
    +The required topology pays one setup wave per standard-hosted lane and retains no shard selectors. The substantive CI inventory consumes enterprise larger-runner minutes only when a benchmark is dispatched; the lightweight aggregate's separate runner choice is outside this decision.
     
    -GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice and transfers one built tree, but isolates coverage, static gates, and post-build consumers from each other's critical paths without repeating the build; consolidating Windows avoids repeating its slower setup.
    +Splitting Linux repeats setup twice and transfers one built tree, but isolates coverage, static gates, and post-build consumers from each other's critical paths without repeating the build. Consolidating Windows avoids repeating its slower setup. The trade-off is longer elapsed time than the measured larger-runner topology.
     
     Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement.
     
    -Missing or renamed enterprise labels leave required primary jobs queued. Standard-hosted compatibility jobs and `master` references still report useful evidence, but they do not substitute for the required aggregate; runner assignment is therefore an operational dependency that repository CI cannot repair.
    +Missing or renamed enterprise labels leave manual benchmarks unavailable without queueing a substantive primary job. The retained pools can compare sizes after allocation recovers without making runner assignment an operational dependency of repository gate execution.
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    index 4787928453..870c4301ee 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    @@ -12,19 +12,19 @@ Status: implemented
     
     ## 决策
     
    -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 3 个 32 核运行器池:Ubuntu 24.04 用于完整覆盖率,Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。
    +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池,作为测量基础设施。公网 IP 已禁用;基准测试并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。
     
    -必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。
    +普通拉取请求使用由[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)规定的标准托管主路径。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)在 `master` 上保留一套独立、完整的标准运行器判定基准。
     
     原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。
     
    -Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
    +Linux 主流程使用 3 项相互独立的标准托管作业,内部均采用单工作线程上限。覆盖率单独运行;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
     
     门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查只构建一次完整的 project-reference 图。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。
     
     产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布契约只要遗漏一个运行时分片,检查仍会失败。
     
    -Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。
    +Windows 以一次标准托管环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约,并采用单工作线程上限。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长关键路径,却不会新增任何阻塞性平台契约。
     
     一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程:
     
    @@ -50,11 +50,11 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
     
     进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于能否得到确定的覆盖率结果,而非标称核心数。
     
    -只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。
    +只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用标准托管的必需路径,大型运行器规格仅通过手动触发运行。
     
     ## 曾考虑的替代方案
     
    -**保留 3 个粗粒度 Linux 主流程通道。** 核心、CPU 和生产网站作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。
    +**恢复原有的核心、CPU 和生产网站通道。** 这些作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。
     
     **将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。
     
    @@ -68,7 +68,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
     
     **将静态门禁和构建后消费方保留在同一台运行器上。** 复用同一个工作区可以省去一轮设置和一次产物传输,但构建耗时的波动会延迟每个消费方,并使消费方的 lint 和快照尾段延续到静态结果之后。仅供本次运行使用的已构建目录树可以保留同一份构建结果,而相互独立的作业能让两条完整路径都保持在实测目标内。
     
    -**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。
    +**将大型运行器池作为必需作业的默认运行环境。** 当运行器能够分配时,此方案可提供实测更低的延迟;但若未获得相应使用权限或企业转移延迟,必需作业会持续排队,且不会发出仓库诊断。可移植路径接受更长的运行时间,手动套件则保留性能实验。
     
     **将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。
     
    @@ -76,10 +76,10 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
     
     ## 后果
     
    -必需拓扑中的每个 32 核通道只承担 1 轮设置开销,且不保留分片选择器。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows 运行器分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。
    +必需拓扑中的每个标准托管通道只承担 1 轮设置开销,且不保留分片选择器。只有在触发基准测试时,实质性 CI 清单才会消耗企业级大型运行器分钟数;轻量级聚合流程单独选择运行器,不属于本决策范围。
     
    -GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置并传输一份已构建目录树,但能使覆盖率、静态门禁与构建后消费方不再相互进入关键路径,且无需重复构建;合并 Windows 则避免重复其耗时更长的设置。
    +拆分 Linux 会重复两轮设置并传输一份已构建目录树,但能使覆盖率、静态门禁与构建后消费方不再相互进入关键路径,且无需重复构建。合并 Windows 可避免重复其耗时更长的设置。代价是总耗时长于经测量的大型运行器拓扑。
     
     性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。
     
    -企业级运行器标签缺失或改名时,必需主作业会持续排队。标准托管兼容性作业与 `master` 参考流程仍会报告有用证据,但不能替代必需聚合流程;因此,运行器分配是一项仓库 CI 无法修复的运维依赖。
    +企业级运行器标签缺失或改名时,手动基准测试会不可用,但不会让实质性主作业排队。运行器分配能力恢复后,保留的运行器池仍可比较不同规格,同时不会让运行器分配成为仓库门禁执行的运维依赖。
    diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
    index 05147cd54a..34ea2f4a90 100644
    --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write
    -2026-07-23-portable-required-pull-request-ci.md: d1002c7d9db7cd8bbed3bdfda8a773a4b124bf16
    -2026-07-23-portable-required-pull-request-ci.zh.md: fedfc6b9c982ace5ece430c52db23c22ec5119d4
    +#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    +2026-07-23-portable-required-pull-request-ci.md: 4fd915a00300922d7318c78d16e1e8078b5170ed
    +2026-07-23-portable-required-pull-request-ci.zh.md: 9d489970a46279de8033cb82af64489d86b20d2c
    diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    index d1002c7d9d..4fd915a003 100644
    --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    @@ -12,24 +12,24 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei
     
     ## Decision
     
    -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs on standard `windows-2025` with single-worker bounds, keeping the complete Windows contract independent of enterprise Windows allocation. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
    +[CI](../../../../.github/workflows/ci.yml) runs the three required primary Node 24 jobs on standard `ubuntu-latest` and the complete required Windows job on standard `windows-2025`. Static gates publish their exact built tree for the snapshot and artifact job, while coverage remains independent. Top-level gates, coverage, ESLint, publint, and snapshot replay use single-worker bounds on these smaller hosts. Node 22.19, Node 26, and Python SDK compatibility also use standard capacity. The lightweight `all checks passed` aggregate remains a separate scheduling decision because it performs no checkout or repository gate.
     
    -The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
    +The three Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; no gate is removed or made observational to recover availability. Branch protection continues to require `e2e` and `all checks passed`.
     
    -The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix.
    +The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the retained performance measurements and manual suites. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check.
     
     ## Alternatives considered
     
    -**Keep the Linux primary jobs and aggregate on standard capacity.** This removes the remaining enterprise allocation dependency, but complete standard-runner jobs give materially slower feedback and still experience shared-capacity queues. The current split retains portable compatibility and serial evidence while spending enterprise capacity on the Linux primary critical path.
    +**Wait for enterprise allocation to recover.** A queue with no assigned runner emits no repository diagnostic and can block every pull request indefinitely, so external recovery is not a correctness path.
     
    -**Select enterprise size from advertised core count.** Benchmarks show non-monotonic scaling and setup variance, so exact complete-job measurements choose the required pools instead.
    +**Use only the smallest enterprise pools.** Every named pool crosses the same enterprise allocation boundary; reducing core count does not remove the dependency that caused the queue.
     
     **Skip or demote checks while capacity is unavailable.** This would make the status green by dropping evidence rather than by running the repository's required contracts.
     
    -**Use one worker policy on every host.** Outer gate concurrency and inner tool workers contend differently on Linux, Windows, and standard runners; measured host-specific bounds avoid turning additional cores into slower execution.
    +**Keep larger-runner worker limits on standard runners.** Concurrent repository gates and their inner worker pools can oversubscribe the smaller memory and CPU allocation, turning an availability repair into contention failures.
     
     ## Consequences
     
    -Ordinary pull requests spend enterprise capacity on the Linux critical path while standard Windows trades longer runtime for independent allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
    +Ordinary pull requests can acquire every substantive runner without enterprise-specific configuration. A live exact-head run proves the same commands that branch protection consumes, at the cost of longer elapsed time on smaller hosts.
     
    -Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work.
    +Manual larger-runner benchmarks can remain queued without blocking pull requests. Restoring larger runners to the required path needs a separate evidence-based decision after exact-head jobs receive nonzero runner IDs and complete reliably; changing a pool definition's status alone is insufficient.
    diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
    index fedfc6b9c9..9d489970a4 100644
    --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
    @@ -12,24 +12,24 @@ Status: implemented
     
     ## 决策
     
    -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `windows-2025` 上运行,并采用单工作线程上限,使完整的 Windows 契约不依赖企业级 Windows 运行器分配。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
    +[CI](../../../../.github/workflows/ci.yml) 在标准 `ubuntu-latest` 上运行 3 项必需的主 Node 24 作业,并在标准 `windows-2025` 上运行完整的必需 Windows 作业。静态门禁发布其完全一致的已构建目录树,供快照与产物作业使用;覆盖率作业则保持独立。这些较小主机上的顶层门禁、覆盖率、ESLint、publint 和快照回放均采用单工作线程上限。Node 22.19、Node 26 和 Python SDK 兼容性也使用标准容量。轻量级 `all checks passed` 聚合流程仍由单独的调度决策管理,因为它不执行代码检出或仓库门禁。
     
    -两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。
    +3 项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;为恢复可用性,没有移除任何门禁,也没有将任何门禁改为仅供观测。分支保护继续要求 `e2e` 和 `all checks passed`。
     
    -当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。
    +保留的性能测量结果与手动套件由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查。
     
     ## 曾考虑的替代方案
     
    -**将 Linux 主作业和聚合流程保留在标准容量上。** 此方案消除了剩余的企业级运行器分配依赖,但标准运行器上的完整作业反馈明显更慢,仍会遇到共享容量排队。当前拆分既保留可移植兼容性和串行证据,又将企业级运行器容量用于 Linux 主关键路径。
    +**等待企业级运行器分配恢复。** 未分配运行器的队列不会发出任何仓库诊断,并且可能无限期阻塞所有拉取请求,因此外部恢复不能作为正确性路径。
     
    -**根据标称核心数选择企业规格。** 基准测试表明扩展效果不呈单调变化,设置耗时也存在波动,因此必需运行器池改由完整作业的精确测量结果选定。
    +**仅使用最小的企业级运行器池。** 无论指定哪个运行器池,都要经过同一个企业级分配边界;减少核心数并不能消除导致排队的依赖。
     
     **在容量不可用时跳过检查或降低其级别。** 这种方式通过丢弃证据而非执行仓库的必需契约来使状态变绿。
     
    -**在每台主机上使用同一工作线程策略。** 外层门禁并发与内层工具工作线程在 Linux、Windows 和标准运行器上的争用方式不同;按主机实测的上限可以避免新增核心反而拖慢执行。
    +**在标准运行器上沿用大型运行器的工作线程上限。** 并发运行的仓库门禁及其内部工作线程池,可能让并发需求超过较小的内存和 CPU 配额,使可用性修复反而引发资源争用故障。
     
     ## 后果
     
    -普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而标准托管 Windows 作业则以更长的运行时间换取不依赖企业池的运行器分配。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。
    +普通拉取请求无需企业专用配置,即可为每项实质性作业获得运行器。一次实际的分支头精确运行能够证明分支保护使用的同一组命令,代价是在较小主机上耗时更长。
     
    -企业级运行器分配能力下降时,标准兼容性作业和必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。
    +手动大型运行器基准测试即使持续排队,也不会阻塞拉取请求。只有在分支头精确作业获得非零运行器 ID 并稳定完成后,才能另行作出基于证据的决策,将大型运行器恢复到必需路径;仅改变运行器池定义的状态仍然不够。
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index f02d30a563..dd49680c6b 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -27,15 +27,15 @@ env:
     
     jobs:
     
    -  # Three enterprise jobs isolate coverage, static analysis, and the
    +  # Three standard Linux jobs isolate coverage, static analysis, and the
       # build-backed consumer tail. The static job publishes its exact build so
       # consumers do not repeat the longest part of their critical path.
       node-24:
         if: github.event_name == 'pull_request'
    -    runs-on: dsh-enterprise-ubuntu-latest-32core-test
    +    runs-on: ubuntu-latest
         name: node 24 / static
         env:
    -      DSH_GATE_CONCURRENCY: '8'
    +      DSH_GATE_CONCURRENCY: '1'
         steps:
           # Fetch complete history so the archive gate can read the trusted PR base from a reused shallow checkout.
           - uses: actions/checkout@v6
    @@ -81,11 +81,11 @@ jobs:
     
       node-24-coverage:
         if: github.event_name == 'pull_request'
    -    runs-on: dsh-enterprise-ubuntu-24-04-32core-test
    +    runs-on: ubuntu-latest
         name: node 24 / coverage
         env:
    -      DSH_COVERAGE_MAX_WORKERS: '24'
    -      DSH_GATE_CONCURRENCY: '8'
    +      DSH_COVERAGE_MAX_WORKERS: '1'
    +      DSH_GATE_CONCURRENCY: '1'
         steps:
           - uses: actions/checkout@v6
             with:
    @@ -122,15 +122,15 @@ jobs:
       node-24-consumers:
         needs: node-24
         if: github.event_name == 'pull_request'
    -    runs-on: dsh-enterprise-ubuntu-latest-32core-test
    +    runs-on: ubuntu-latest
         name: node 24 / snapshots and artifacts
         env:
           DSH_ESLINT_CACHE: '1'
    -      DSH_ESLINT_CONCURRENCY: '8'
    -      DSH_GATE_CONCURRENCY: '8'
    +      DSH_ESLINT_CONCURRENCY: '1'
    +      DSH_GATE_CONCURRENCY: '1'
           DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
    -      DSH_PUBLINT_CONCURRENCY: '8'
    -      DSH_SNAPSHOT_MAX_CONCURRENCY: '32'
    +      DSH_PUBLINT_CONCURRENCY: '1'
    +      DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
         steps:
           - uses: actions/checkout@v6
             with:
    
    From 1a8225ee6cc62438a0c7c54e19ee76ba76b61b33 Mon Sep 17 00:00:00 2001
    From: Ubuntu 
    Date: Mon, 27 Jul 2026 14:35:05 +0800
    Subject: [PATCH 31/56] ci: retrigger after failover switch
    
    
    From 7bd96af5eb8d889b0652f1d4972a4e3e4c9649e2 Mon Sep 17 00:00:00 2001
    From: NI0317 
    Date: Mon, 27 Jul 2026 14:37:04 +0800
    Subject: [PATCH 32/56] fix(workspace): make deletion recoverable
    
    ---
     ...-workspace-registration-deletion.i18n.yaml |   4 +-
     ...6-07-27-workspace-registration-deletion.md |   8 +-
     ...7-27-workspace-registration-deletion.zh.md |   8 +-
     apps/web/tests/workspace-management.e2e.ts    |  29 ++++
     .../runtime/src/client/workspaces/manager.ts  |  10 +-
     .../ui-workspace/src/client/rows/Rows.tsx     |   4 +
     .../tests/api-proxy-workspace.spec.ts         |   6 +
     packages/workspace/workspace/README.i18n.yaml |   4 +-
     packages/workspace/workspace/README.md        |   2 +
     packages/workspace/workspace/README.zh.md     |   2 +
     packages/workspace/workspace/src/index.ts     |  75 ++++++++-
     packages/workspace/workspace/src/spec.ts      |  11 ++
     .../workspace/tests/workspace.spec.ts         | 151 +++++++++++++++++-
     13 files changed, 294 insertions(+), 20 deletions(-)
    
    diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml
    index 847b040457..93c78373c6 100644
    --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml
    +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
    -2026-07-27-workspace-registration-deletion.md: cae01d529bc6fd97da6fb61839bd5ec8e21557e2
    -2026-07-27-workspace-registration-deletion.zh.md: 76377ebc5e93101e1e3efce1d29c3c654df032c2
    +2026-07-27-workspace-registration-deletion.md: 58ae5c4bef2cf1cb0a0158eda5eb37daf2e9703d
    +2026-07-27-workspace-registration-deletion.zh.md: 7a79a1ccc53a0d4fd7e5ab453239ade955313c6e
    diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
    index cae01d529b..58ae5c4bef 100644
    --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
    +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
    @@ -22,6 +22,8 @@ Registry operations serialize create and delete. Deletion first writes the Works
     
     The Host stream keeps its committed-id set through the preceding global-order write and removes the id only on the table deletion. Create rollback therefore emits no false removal, while every connected tab receives exactly the id needed to delete its projection.
     
    +Create and delete write a durable `pendingMutation` before their record/order pair can diverge. Startup completes only the named create or delete and clears the marker; it never infers crash provenance from an orphan row alone. Unmarked order/table divergence therefore retains the registry's fail-loud corruption behavior. A deletion whose table write committed but marker cleanup failed still reports success—the requested state and removal frame are already committed—and the next startup clears that marker idempotently.
    +
     ## Client convergence
     
     `WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta.
    @@ -40,14 +42,16 @@ The menu, Modal, and buttons retain their existing structure and design tokens.
     
     **Delete the table row and repair order later.** Rejected because a crash or write failure would leave an initialized registry whose order and table disagree. The registry updates both under one serialized operation and restores the prior order on table failure.
     
    +**Delete every unreferenced row at startup.** Rejected because the same shape can come from unexplained order corruption; silently discarding it could lose Workspace metadata and Session accounting. Recovery requires the explicit pending marker written by the owning mutation.
    +
     **Refetch both lists after success.** Rejected because the committed removal frame plus immediate unary echo is sufficient, preserves the current Session object, and avoids turning a local mutation into two list requests. Reconnect baselines remain the repair path.
     
     ## Verification
     
    -Workspace package tests pin successful metadata-only deletion, unknown-id idempotence, table-failure rollback, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close.
    +Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close.
     
     The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload.
     
     ## Consequences
     
    -Deleting a Workspace is intentionally reversible by registering the same directory again, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns.
    +Deleting a Workspace is intentionally reversible by registering the same directory again with a fresh id, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns.
    diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md
    index 76377ebc5e..7a79a1ccc5 100644
    --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md
    +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md
    @@ -22,6 +22,8 @@ Workspace 注册已有代码目录,使 GUI 能够为目录命名,并对其
     
     Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合,只在删除表行时移除该 id。因此,创建回滚不会发出错误的移除帧,而每个已连接标签页都能收到从自身投影中删除该记录所需的准确 id。
     
    +Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendingMutation`。启动时只补全其中明确命名的 create 或 delete,并清除该标记;系统绝不会仅凭孤立表行的形状推断崩溃来源。因此,没有标记的顺序/表分叉仍会保持注册表原有的损坏直接失败语义。如果删除的表写入已经提交、但标记清理失败,操作仍会报告成功——请求状态和移除帧都已经提交——下一次启动会以幂等方式清除该标记。
    +
     ## 客户端收敛
     
     `WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。
    @@ -40,14 +42,16 @@ Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合
     
     **先删除表行,之后再修复顺序。** 不予采纳,因为崩溃或写入失败会使已初始化注册表的顺序与表不一致。注册表会在同一串行操作内更新二者,并在表操作失败时恢复此前顺序。
     
    +**启动时删除所有未引用表行。** 不予采纳,因为来源不明的顺序损坏也会呈现相同形状;静默丢弃可能损失 Workspace 元数据和 Session 账本。恢复必须依赖拥有该变更的操作预先写入的明确待处理标记。
    +
     **成功后重新拉取两个列表。** 不予采纳,因为已提交的移除帧与即时一元回显已足够,既能保留当前会话对象,也避免将局部变更扩大为两次列表请求。重连基线仍是修复路径。
     
     ## Verification
     
    -Workspace 包测试固定了仅删除元数据的成功路径、未知 id 的幂等行为、表操作失败回滚,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。
    +Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。
     
     组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。
     
     ## Consequences
     
    -删除 Workspace 后仍可重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。
    +删除 Workspace 后仍可使用新 id 重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。
    diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts
    index 4dbcca36ae..3239dcfc12 100644
    --- a/apps/web/tests/workspace-management.e2e.ts
    +++ b/apps/web/tests/workspace-management.e2e.ts
    @@ -169,6 +169,34 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
         await stat(logLocation.path)
         expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
     
    +    // Re-registering the exact deleted path immediately, without a reload, is
    +    // a supported reversible flow. It creates a fresh Workspace id without
    +    // re-adopting the retained Session.
    +    await page.getByRole('button', { name: 'Create workspace' }).click()
    +    await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
    +    await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
    +    const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' })
    +    await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd)
    +    await reuseFolder.getByRole('button', { name: 'Use folder' }).click()
    +    await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0)
    +    const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
    +    expect(reregistered?.id).toBeDefined()
    +    expect(reregistered?.id).not.toBe(workspace.id)
    +    expect(reregistered?.sessionIds).toEqual([])
    +    await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
    +      .toBeGreaterThanOrEqual(1)
    +    expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
    +    await stat(logLocation.path)
    +
    +    // Restore the deleted-registry state so reload still verifies deletion
    +    // persistence independently of the successful re-registration above.
    +    if (reregistered === undefined) throw new Error('same-path re-registration did not materialize')
    +    await scaffold.ctx.workspace.delete(reregistered.id)
    +    await expect.poll(
    +      () => page.getByRole('button', { name: `Workspace actions for ${reregistered.title}` }).count(),
    +      { timeout: 10_000 },
    +    ).toBe(0)
    +
         const warningStart = tripwire.warnings.length
         await page.reload({ waitUntil: 'load' })
         await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
    @@ -183,6 +211,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
         expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
         await stat(logLocation.path)
         expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
    +
         expect(tripwire.pageErrors).toEqual([])
       }, 90_000)
     
    diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts
    index 83275e9a2a..7179ed9eb9 100644
    --- a/packages/client/runtime/src/client/workspaces/manager.ts
    +++ b/packages/client/runtime/src/client/workspaces/manager.ts
    @@ -33,6 +33,14 @@ export class WorkspaceManager {
       private error: RpcError | null = null
       private inflight: Promise | null = null
       private refreshFrames: WorkspaceDelta[] | null = null
    +  /**
    +   * Ids this process has seen removed, kept for the connection's lifetime so
    +   * a late changed frame or a stale baseline row cannot resurrect a deleted
    +   * row. Correctness rests on Host ids never being reused (the registry mints
    +   * a fresh `randomUUID` per record, including when the same directory is
    +   * registered again) — a path-derived id scheme would turn these entries
    +   * into permanent blindfolds and must clear them instead.
    +   */
       private readonly removedIds = new Set()
       private snapshotCache: WorkspaceListSnapshot
       private readonly notifier = new Notifier(() => {
    @@ -266,7 +274,7 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi
         : items.map((item, position) => position === index ? workspace : item)
     }
     
    -
    +/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
     function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
       return delta.type === 'upsert'
         ? upsertWorkspace(items, delta.workspace)
    diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx
    index ae866f58cf..e245f8b217 100644
    --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx
    +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx
    @@ -75,6 +75,10 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
                 items={WORKSPACE_MENU_ITEMS}
                 onSelect={(id) => {
                   setMenuOpen(false)
    +              // Unknown ids leave before the dispatch: a future menu row must
    +              // not inherit the destructive branch as an else fallback.
    +              /* v8 ignore next -- WORKSPACE_MENU_ITEMS carries exactly these two rows today. */
    +              if (id !== 'rename' && id !== 'delete') return
                   if (id === 'rename') actions.rename()
                   else actions.delete()
                 }}
    diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
    index d5ba628590..bbd57cb6dc 100644
    --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
    +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
    @@ -269,6 +269,12 @@ describe('Host Workspace increments', () => {
           ok: false,
           error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
         })
    +
    +    const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
    +    expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
    +    expect(reregistered.path).toBe(workspace.path)
    +    expect(reregistered.sessionIds).toEqual([])
    +    expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
         abort.abort()
       })
     })
    diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml
    index 0904711ad3..b5eaefa98c 100644
    --- a/packages/workspace/workspace/README.i18n.yaml
    +++ b/packages/workspace/workspace/README.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md
    -README.md: 52d03b33b3482dcb6a2f5feddbc15ac9fefee0a8
    -README.zh.md: f899abdc3dd2a551179cd710c6dda84f804a8e80
    +README.md: bee3e4fcb5dded273f30942ee2e42ee93b839e62
    +README.zh.md: 7960a2d13df4f881687fd88cdb07e237b3abb7c8
    diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md
    index 52d03b33b3..bee3e4fcb5 100644
    --- a/packages/workspace/workspace/README.md
    +++ b/packages/workspace/workspace/README.md
    @@ -18,6 +18,8 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
     
     `storageDomain` and `sessionPersistence` are required startup dependencies. An unavailable peer leaves the plugin pending and cannot commit an empty initialized marker. On the first successful start, the registry calls `SessionPersistence.list()` and uses only header `id`, `cwd`, and `createdAt` to group valid historical directories and persist initial order; it never reads event bodies. The initialized marker is written last, so partial bootstrap writes are reused safely after restart. Later cwd-only sessions remain Ungrouped.
     
    +Create and delete persist an explicit pending-mutation marker before their record and order can diverge. Startup completes only the marked mutation, then clears the marker; an unmarked order/table mismatch remains unexplained corruption and fails loud. Deleting and re-registering the same path creates a fresh Workspace id and does not automatically re-adopt the retained Sessions.
    +
     ## Model Experience
     
     ### Workspace records and session accounts
    diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md
    index f899abdc3d..7960a2d13d 100644
    --- a/packages/workspace/workspace/README.zh.md
    +++ b/packages/workspace/workspace/README.zh.md
    @@ -18,6 +18,8 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
     
     `storageDomain` 和 `sessionPersistence` 是启动必需依赖。对等服务不可用时,插件保持待处理,且不能提交空的已初始化标记。首次成功启动时,注册表调用 `SessionPersistence.list()`,仅使用头部 `id`、`cwd` 和 `createdAt` 对有效历史目录分组并持久化初始顺序;它绝不读取事件正文。已初始化标记最后写入,因此重启后可安全复用部分启动写入。后续仅有 cwd 的会话仍属于 Ungrouped。
     
    +Create 与 delete 会在记录和顺序可能分叉之前,先持久化明确的待处理变更标记。启动时只补全被该标记证明的变更,随后清除标记;没有标记的顺序/表不一致仍属于来源不明的损坏,并会直接失败。删除后重新注册同一路径会生成新的 Workspace id,且不会自动重新接纳保留下来的 Session。
    +
     ## 模型体验
     
     ### Workspace 记录与会话记账
    diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts
    index 2699365608..5172c63805 100644
    --- a/packages/workspace/workspace/src/index.ts
    +++ b/packages/workspace/workspace/src/index.ts
    @@ -109,6 +109,7 @@ export class WorkspaceRegistry extends Service {
         this.global = domain.global
         this.state = domain.global.get()
     
    +    await this.recoverPendingMutation()
         this.validateStoredState(this.state)
         if (!this.state.initialized) {
           const headers = await this.ctx.sessionPersistence.list()
    @@ -218,10 +219,28 @@ export class WorkspaceRegistry extends Service {
         }
         const entity = new WorkspaceEntity(this.host, id, record)
         this.entities.set(id, entity)
    +    const pendingState: WorkspaceDomainState = {
    +      ...state,
    +      pendingMutation: { operation: 'create', workspaceId: id },
    +    }
    +    try {
    +      await this.setState(pendingState)
    +    } catch (error) {
    +      this.entities.delete(id)
    +      throw error
    +    }
         try {
           await table.put(id, record)
         } catch (error) {
           this.entities.delete(id)
    +      try {
    +        await this.setState(state)
    +      } catch (rollbackError) {
    +        throw new AggregateError(
    +          [error, rollbackError],
    +          `workspace '${id}' record write and pending-marker rollback both failed`,
    +        )
    +      }
           throw error
         }
     
    @@ -232,10 +251,17 @@ export class WorkspaceRegistry extends Service {
           try {
             await table.delete(id)
           } catch (rollbackError) {
    -        this.entities.set(id, entity)
             throw new AggregateError(
               [error, rollbackError],
    -          `workspace '${id}' was stored but its registry order and rollback both failed`,
    +          `workspace '${id}' order write and record rollback both failed; the pending marker remains recoverable`,
    +        )
    +      }
    +      try {
    +        await this.setState(state)
    +      } catch (rollbackError) {
    +        throw new AggregateError(
    +          [error, rollbackError],
    +          `workspace '${id}' order write and pending-marker rollback both failed`,
             )
           }
           throw error
    @@ -251,7 +277,10 @@ export class WorkspaceRegistry extends Service {
           initialized: true,
           workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
         }
    -    await this.setState(nextState)
    +    await this.setState({
    +      ...nextState,
    +      pendingMutation: { operation: 'delete', workspaceId: id },
    +    })
         this.entities.delete(id)
         try {
           await this.requireTable().delete(id)
    @@ -260,6 +289,10 @@ export class WorkspaceRegistry extends Service {
           try {
             await this.setState(state)
           } catch (rollbackError) {
    +        // The durable marker still says to finish deletion, so the cache must
    +        // agree with that recoverable direction rather than republish a row
    +        // absent from the persisted order.
    +        this.entities.delete(id)
             throw new AggregateError(
               [error, rollbackError],
               `workspace '${id}' record deletion and registry-order rollback both failed`,
    @@ -267,9 +300,38 @@ export class WorkspaceRegistry extends Service {
           }
           throw error
         }
    +    try {
    +      await this.setState(nextState)
    +    } catch (error) {
    +      // The deletion committed at the table write and was already published
    +      // to Host streams. Keep the durable marker for startup recovery rather
    +      // than reporting failure after the requested state became true.
    +      this.ctx.logger.warn(
    +        `workspace '${id}' was deleted but its pending marker could not be cleared: ${String(error)}`,
    +      )
    +    }
         return true
       }
     
    +  /**
    +   * Complete the one mutation explicitly named by durable state. Unexplained
    +   * order/table divergence still reaches {@link validateStoredState} and
    +   * fails loud; this path never infers provenance from shape alone.
    +   */
    +  private async recoverPendingMutation(): Promise {
    +    const state = this.requireState()
    +    const pending = state.pendingMutation
    +    if (pending === undefined) return
    +    if (state.workspaceIds.includes(pending.workspaceId)) {
    +      throw new Error(
    +        `workspace domain is inconsistent: pending ${pending.operation} workspace `
    +        + `'${pending.workspaceId}' is still present in registry order`,
    +      )
    +    }
    +    await this.requireTable().delete(pending.workspaceId)
    +    await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds })
    +  }
    +
       private async bootstrap(headers: readonly SessionHeader[]): Promise {
         const table = this.requireTable()
         const state = this.requireState()
    @@ -493,7 +555,12 @@ export class WorkspaceRegistry extends Service {
       }
     
       private enqueueOperation(operation: () => Promise): Promise {
    -    const result = this.operationTail.then(operation)
    +    const result = this.operationTail.then(async () => {
    +      // A committed delete may leave only its marker cleanup pending. Retry
    +      // recovery before another create/delete can overwrite that provenance.
    +      await this.recoverPendingMutation()
    +      return await operation()
    +    })
         this.operationTail = result.then(() => {}, () => {})
         return result
       }
    diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts
    index 8df908949a..7b1a6a41d0 100644
    --- a/packages/workspace/workspace/src/spec.ts
    +++ b/packages/workspace/workspace/src/spec.ts
    @@ -29,6 +29,16 @@ export const workspaceRecord = z.object({
     /** One stored workspace record, inferred from {@link workspaceRecord}. */
     export type WorkspaceRecord = z.infer
     
    +/**
    + * Recoverable two-write mutation marker. The marker is persisted before the
    + * record/order pair can diverge, so startup can distinguish an interrupted
    + * registry operation from unexplained medium corruption.
    + */
    +const workspacePendingMutation = z.discriminatedUnion('operation', [
    +  z.object({ operation: z.literal('create'), workspaceId }),
    +  z.object({ operation: z.literal('delete'), workspaceId }),
    +])
    +
     /**
      * Durable registry state. `initialized` distinguishes a valid empty registry
      * from one that still needs the header-only history bootstrap;
    @@ -37,6 +47,7 @@ export type WorkspaceRecord = z.infer
     export const workspaceDomainState = z.object({
       initialized: z.boolean(),
       workspaceIds: z.array(workspaceId),
    +  pendingMutation: workspacePendingMutation.optional(),
     })
     
     /** Durable registry state inferred from {@link workspaceDomainState}. */
    diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts
    index ed08b5ba50..4576155f3b 100644
    --- a/packages/workspace/workspace/tests/workspace.spec.ts
    +++ b/packages/workspace/workspace/tests/workspace.spec.ts
    @@ -89,7 +89,7 @@ async function storageContext(pool: MemoryMediaPool, backend: StorageBackend = n
     /** Backend wrapper that injects one selected bootstrap write failure. */
     function selectiveFailureBackend(
       pool: MemoryMediaPool,
    -  failure: { putAt?: number; deleteAt?: number; globalAt?: number },
    +  failure: { putAt?: number; deleteAt?: number; globalAt?: number | readonly number[] },
     ): StorageBackend {
       const inner = new MemoryStorageBackend(pool)
       let puts = 0
    @@ -113,7 +113,8 @@ function selectiveFailureBackend(
               },
               setGlobal: async (value) => {
                 globals += 1
    -            if (globals === failure.globalAt) throw new Error('selected bootstrap marker failure')
    +            const failAt = Array.isArray(failure.globalAt) ? failure.globalAt : [failure.globalAt]
    +            if (failAt.includes(globals)) throw new Error('selected bootstrap marker failure')
                 await unit.setGlobal(value)
               },
               close: () => unit.close(),
    @@ -394,19 +395,34 @@ describe('WorkspaceRegistry create and lookup', () => {
     
       it('rolls back the provisional cache when the record write fails', async () => {
         const dir = await makeDir('write-failure')
    -    const result = await harness()
    -    result.pool.failNextWrites = 1
    -    await expect(result.registry.create(dir)).rejects.toThrow(/injected/)
    +    const pool = new MemoryMediaPool()
    +    const result = await harness({
    +      pool,
    +      backend: selectiveFailureBackend(pool, { putAt: 1 }),
    +    })
    +    await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap put failure/)
         expect(result.registry.list()).toEqual([])
         expect(await result.registry.create(dir)).toBeDefined()
       })
     
    +  it('does not publish a Workspace when its pending marker cannot be written', async () => {
    +    const dir = await makeDir('pending-marker-write-failure')
    +    const pool = new MemoryMediaPool()
    +    const result = await harness({
    +      pool,
    +      backend: selectiveFailureBackend(pool, { globalAt: 2 }),
    +    })
    +    await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap marker failure/)
    +    expect(result.registry.list()).toEqual([])
    +    expect(pool.media.get('workspace')!.tables.get('workspaces')?.size ?? 0).toBe(0)
    +  })
    +
       it('rolls back a record when registry-order persistence fails', async () => {
         const dir = await makeDir('order-write-failure')
         const pool = new MemoryMediaPool()
         const result = await harness({
           pool,
    -      backend: selectiveFailureBackend(pool, { globalAt: 2 }),
    +      backend: selectiveFailureBackend(pool, { globalAt: 3 }),
         })
         await expect(result.registry.create(dir)).rejects.toThrow(/marker failure/)
         expect(result.registry.list()).toEqual([])
    @@ -418,12 +434,38 @@ describe('WorkspaceRegistry create and lookup', () => {
         const pool = new MemoryMediaPool()
         const result = await harness({
           pool,
    -      backend: selectiveFailureBackend(pool, { globalAt: 2, deleteAt: 1 }),
    +      backend: selectiveFailureBackend(pool, { globalAt: 3, deleteAt: 1 }),
         })
         await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
         expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
       })
     
    +  it('reports a record write and pending-marker rollback failure together', async () => {
    +    const dir = await makeDir('record-marker-rollback-failure')
    +    const pool = new MemoryMediaPool()
    +    const result = await harness({
    +      pool,
    +      backend: selectiveFailureBackend(pool, { putAt: 1, globalAt: 3 }),
    +    })
    +    await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
    +    expect(storedState(pool)).toMatchObject({
    +      pendingMutation: { operation: 'create' },
    +    })
    +  })
    +
    +  it('reports an order write and pending-marker rollback failure together', async () => {
    +    const dir = await makeDir('order-marker-rollback-failure')
    +    const pool = new MemoryMediaPool()
    +    const result = await harness({
    +      pool,
    +      backend: selectiveFailureBackend(pool, { globalAt: [3, 4] }),
    +    })
    +    await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
    +    expect(storedState(pool)).toMatchObject({
    +      pendingMutation: { operation: 'create' },
    +    })
    +  })
    +
       it('deletes only the registration and leaves its directory and session headers untouched', async () => {
         const dir = await makeDir('delete-registration')
         const result = await harness({ sessions: [header('kept-session', dir)] })
    @@ -440,6 +482,11 @@ describe('WorkspaceRegistry create and lookup', () => {
         expect(result.list).toHaveBeenCalledTimes(1)
         expect(result.load).not.toHaveBeenCalled()
         expect(result.inspect).not.toHaveBeenCalled()
    +
    +    const reregistered = await result.registry.create(dir)
    +    expect(reregistered.id).not.toBe(workspace.id)
    +    expect(reregistered.path).toBe(dir)
    +    expect(reregistered.sessionIds).toEqual([])
       })
     
       it('rolls registry order and cache back when record deletion fails', async () => {
    @@ -458,11 +505,58 @@ describe('WorkspaceRegistry create and lookup', () => {
         expect(storedRecord(pool, workspace.id)).toMatchObject({ path: dir })
       })
     
    +  it('commits deletion and leaves a recoverable marker when marker cleanup fails', async () => {
    +    const dir = await makeDir('delete-marker-cleanup')
    +    const pool = new MemoryMediaPool()
    +    const first = await harness({
    +      pool,
    +      backend: selectiveFailureBackend(pool, { globalAt: 5 }),
    +    })
    +    const workspace = await first.registry.create(dir)
    +
    +    await expect(first.registry.delete(workspace.id)).resolves.toBe(true)
    +    expect(first.registry.list()).toEqual([])
    +    expect(storedState(pool)).toEqual({
    +      initialized: true,
    +      workspaceIds: [],
    +      pendingMutation: { operation: 'delete', workspaceId: workspace.id },
    +    })
    +    const reregistered = await first.registry.create(dir)
    +    expect(reregistered.id).not.toBe(workspace.id)
    +    expect(storedState(pool)).toEqual({
    +      initialized: true,
    +      workspaceIds: [reregistered.id],
    +    })
    +    await first.fiber.dispose()
    +
    +    const restarted = await harness({ pool })
    +    expect(restarted.registry.list().map(item => item.id)).toEqual([reregistered.id])
    +  })
    +
    +  it('keeps the failed deletion unpublished when record and order rollback both fail', async () => {
    +    const dir = await makeDir('delete-double-failure')
    +    const pool = new MemoryMediaPool()
    +    const result = await harness({
    +      pool,
    +      backend: selectiveFailureBackend(pool, { deleteAt: 1, globalAt: 5 }),
    +    })
    +    const workspace = await result.registry.create(dir)
    +
    +    await expect(result.registry.delete(workspace.id)).rejects.toBeInstanceOf(AggregateError)
    +    expect(result.registry.get(workspace.id)).toBeUndefined()
    +    expect(storedState(pool)).toMatchObject({
    +      workspaceIds: [],
    +      pendingMutation: { operation: 'delete', workspaceId: workspace.id },
    +    })
    +  })
    +
       it('rejects table access before the registry has started', async () => {
         const dir = await makeDir('unstarted')
         const registry = new WorkspaceRegistry(new Context())
         await expect(registry.create(dir)).rejects.toThrow(/not started/)
         expect(() => registry.list()).toThrow(/not started/)
    +    const internals = registry as unknown as { requireTable(): unknown }
    +    expect(() => internals.requireTable()).toThrow(/not started/)
       })
     })
     
    @@ -650,6 +744,49 @@ describe('header-validated membership projection', () => {
         internals.entities.delete(workspace.id)
         expect(() => result.registry.list()).toThrow(/references missing workspace/)
       })
    +
    +  it('recovers only an explicitly marked interrupted create or delete', async () => {
    +    const createDir = await makeDir('pending-create')
    +    const deleteDir = await makeDir('pending-delete')
    +    const createId = WorkspaceId('00000000-0000-4000-8000-000000000004')
    +    const deleteId = WorkspaceId('00000000-0000-4000-8000-000000000005')
    +
    +    const interruptedCreate = storedPool(
    +      [[createId, record(createDir, [])]],
    +      {
    +        initialized: true,
    +        workspaceIds: [],
    +        pendingMutation: { operation: 'create', workspaceId: createId },
    +      },
    +    )
    +    const createRecovery = await harness({ pool: interruptedCreate })
    +    expect(createRecovery.registry.list()).toEqual([])
    +    expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false)
    +    expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] })
    +
    +    const interruptedDelete = storedPool(
    +      [[deleteId, record(deleteDir, [])]],
    +      {
    +        initialized: true,
    +        workspaceIds: [],
    +        pendingMutation: { operation: 'delete', workspaceId: deleteId },
    +      },
    +    )
    +    const deleteRecovery = await harness({ pool: interruptedDelete })
    +    expect(deleteRecovery.registry.list()).toEqual([])
    +    expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false)
    +    expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] })
    +
    +    const corruptPending = storedPool(
    +      [[deleteId, record(deleteDir, [])]],
    +      {
    +        initialized: true,
    +        workspaceIds: [deleteId],
    +        pendingMutation: { operation: 'delete', workspaceId: deleteId },
    +      },
    +    )
    +    await expect(harness({ pool: corruptPending })).rejects.toThrow(/still present in registry order/)
    +  })
     })
     
     describe('workspace mutation and status', () => {
    
    From be80eb04ad4876dd3c60e000d9b7e1836bed3a1f Mon Sep 17 00:00:00 2001
    From: Ubuntu 
    Date: Mon, 27 Jul 2026 14:45:54 +0800
    Subject: [PATCH 33/56] ci: retrigger after runner-group policy fix
    
    
    From fe246e4a0a14a4ce154e05e52188bac098dea80c Mon Sep 17 00:00:00 2001
    From: Chinesezjc 
    Date: Mon, 27 Jul 2026 15:17:48 +0800
    Subject: [PATCH 34/56] =?UTF-8?q?ci:=20failover=20round=20=E2=80=94=20aggr?=
     =?UTF-8?q?egate=20follows=20the=20selector,=20tighter=20shared-VM=20bound?=
     =?UTF-8?q?s?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    - all-checks-passed now resolves its pool through the same
      DSH_CI_FAILOVER expression as the worker jobs it aggregates.
      Pinned to the hosted pool it would leave the branch-protection
      verdict queued on the failed pool after every failover job passed —
      observed live during the 2026-07-27 outage as a required check
      looping against dead capacity.
    - Coverage worker bound under failover drops 12 → 8 and snapshot
      concurrency 16 → 12: the pool now runs six always-on instances (the
      spare tier was retired), so worst case is 6 × 8 = 48 coverage
      workers on the shared 64-core VM.
    ---
     .github/workflows/ci.yml | 22 +++++++++++++++-------
     1 file changed, 15 insertions(+), 7 deletions(-)
    
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index 5afa5d7f62..df3d386e39 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -102,11 +102,12 @@ jobs:
               || 'dsh-enterprise-ubuntu-24-04-32core-test' }}
         name: node 24 / coverage
         env:
    -      # Failover halves the worker bound: the hosted 32-core runner is
    +      # Failover shrinks the worker bound: the hosted 32-core runner is
           # exclusive to one job, but the failover pool shares one 64-core VM
    -      # across four runner instances, and the timing-sensitive process
    -      # suites have documented aggregate-contention failures.
    -      DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '12' || '24' }}
    +      # across six always-on runner instances, and the timing-sensitive
    +      # process suites have documented aggregate-contention failures.
    +      # 8 × 6 instances = 48 workers worst case on 64 cores.
    +      DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '8' || '24' }}
           DSH_GATE_CONCURRENCY: '8'
         steps:
           - uses: actions/checkout@v6
    @@ -160,7 +161,7 @@ jobs:
           DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
           DSH_PUBLINT_CONCURRENCY: '8'
           # Failover halves snapshot concurrency for the shared 64-core VM.
    -      DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '16' || '32' }}
    +      DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '12' || '32' }}
         steps:
           - uses: actions/checkout@v6
             with:
    @@ -765,8 +766,15 @@ jobs:
       # 'cancelled' and 'skipped'.
       all-checks-passed:
         name: all checks passed
    -    # The required verdict must not add a separate standard-hosted billing dependency.
    -    runs-on: dsh-enterprise-ubuntu-latest-32core-test
    +    # The required verdict must not add a separate standard-hosted billing
    +    # dependency — and it must follow the failover selector like the worker
    +    # jobs it aggregates: if it stayed pinned to the hosted pool, every
    +    # failover-passed run would still leave the branch-protection verdict
    +    # queued forever on the failed pool.
    +    runs-on: >-
    +      ${{ vars.DSH_CI_FAILOVER == 'selfhosted'
    +          && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
    +          || 'dsh-enterprise-ubuntu-latest-32core-test' }}
         needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows]
         if: always() && github.event_name == 'pull_request'
         steps:
    
    From aedf7fbf349df99a89faa72ddc17ec95ea2aff53 Mon Sep 17 00:00:00 2001
    From: Chinesezjc 
    Date: Mon, 27 Jul 2026 15:27:40 +0800
    Subject: [PATCH 35/56] docs(i18n): keep the runbook link target identical
     across the pair
    
    The pairing gate requires link target #9 to be byte-identical between
    the language sides; my earlier 'fix' pointed the zh side at the zh
    runbook and broke the contract. Reverted to the shared target and
    re-recorded the pairing hash.
    ---
     .../2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml   | 2 +-
     .../2026-07-22-evidence-based-larger-hosted-runners.zh.md       | 2 +-
     2 files changed, 2 insertions(+), 2 deletions(-)
    
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    index 5ebd95248c..99cabc76bb 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    @@ -3,4 +3,4 @@
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
     2026-07-22-evidence-based-larger-hosted-runners.md: 5b399be5571ddaf1f775ba43a2233198b8e09b18
    -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 40970ec33c1a16af85ea47be3fc932209efdd654
    +2026-07-22-evidence-based-larger-hosted-runners.zh.md: f77516e2375bfc0557679d05fd275bd9cee7d8eb
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    index 40970ec33c..f77516e237 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
     
     只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。
     
    -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.zh.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。
    +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。
     
     ## 曾考虑的替代方案
     
    
    From be34ae6b86e3f63dfd4ca2e1bb39e51b5027863b Mon Sep 17 00:00:00 2001
    From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
    Date: Mon, 27 Jul 2026 15:32:05 +0800
    Subject: [PATCH 36/56] ci: run required status aggregator on ubuntu-latest
    
    ---
     .github/workflows/ci.yml | 4 ++--
     1 file changed, 2 insertions(+), 2 deletions(-)
    
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index f02d30a563..413a3cb51b 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -693,8 +693,8 @@ jobs:
       # 'cancelled' and 'skipped'.
       all-checks-passed:
         name: all checks passed
    -    # The required verdict must not add a separate standard-hosted billing dependency.
    -    runs-on: dsh-enterprise-ubuntu-latest-32core-test
    +    # This bookkeeping-only verdict must not depend on custom-pool provisioning.
    +    runs-on: ubuntu-latest
         needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows]
         if: always() && github.event_name == 'pull_request'
         steps:
    
    From 9c53cd7d072268599b70f54ecb76e6b19379aeb5 Mon Sep 17 00:00:00 2001
    From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
    Date: Mon, 27 Jul 2026 15:33:36 +0800
    Subject: [PATCH 37/56] test: stabilize timing-sensitive terminal checks
    
    ---
     ...26-07-16-persistent-pty-sessions.i18n.yaml |  6 +-
     .../2026-07-16-persistent-pty-sessions.md     |  2 +-
     .../2026-07-16-persistent-pty-sessions.zh.md  |  2 +-
     ...-18-tui-terminal-state-snapshots.i18n.yaml |  6 +-
     ...2026-07-18-tui-terminal-state-snapshots.md |  2 +-
     ...6-07-18-tui-terminal-state-snapshots.zh.md |  2 +-
     packages/pty/pty-local/tests/local.spec.ts    | 28 +++++---
     packages/ui/tui/tests/tui.snapshot.ts         | 64 +++++++++++--------
     8 files changed, 66 insertions(+), 46 deletions(-)
    
    diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
    index f7e242b78c..4c73d590af 100644
    --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
    +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write
    -2026-07-16-persistent-pty-sessions.md: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95
    -2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8
    +#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
    +2026-07-16-persistent-pty-sessions.md: 43c87bb159cfe1ab9f8d3a80c2adf25a57ae6e3b
    +2026-07-16-persistent-pty-sessions.zh.md: 8afc2103447cc58b1fcbc1062b9564e8ed643477
    diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
    index 148d4a2f47..43c87bb159 100644
    --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
    +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
    @@ -154,7 +154,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
     
     - Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
     - Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
    -- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
    +- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
     - A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation.
     - Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface.
     - The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification.
    diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
    index 9a9d9cd4b0..8afc210344 100644
    --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
    +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
    @@ -154,7 +154,7 @@ plugins:
     
     - 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
     - Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
    -- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 下的前台 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。
    +- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。
     - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。
     - 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。
     - 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。
    diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml
    index 133198a4d2..d491394db7 100644
    --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml
    +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write
    -2026-07-18-tui-terminal-state-snapshots.md: 8e86588f69fdb9d615232252ecf57309d440f1cd
    -2026-07-18-tui-terminal-state-snapshots.zh.md: b70a46830f44e9da663e30745fcdb7ad281592da
    +#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md
    +2026-07-18-tui-terminal-state-snapshots.md: 18c79bc2d0dabf4d78887354f30a2cdc083899e1
    +2026-07-18-tui-terminal-state-snapshots.zh.md: d1d4a6ca859e94a153e0bf645a17f03c0dac234b
    diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md
    index 8e86588f69..18c79bc2d0 100644
    --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md
    +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md
    @@ -33,7 +33,7 @@ The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their pr
     
     ### Semantic terminal projection
     
    -The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state, so a checkpoint represents a completed screen rather than a timer-dependent write prefix.
    +The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state. The streaming checkpoint freezes the loader interval while allowing real wall-clock delay across one animation tick, so it pins semantic status rather than whichever spinner glyph the scheduler happened to render.
     
     Each expected output projects dimensions, active-buffer and viewport coordinates, lifecycle and cursor state, rows, wrap markers, and non-default style ranges into text. Scroll-heavy cards capture the used buffer; overlays capture the visible viewport. Text and style remain separate so a reviewer can distinguish content changes from presentation changes without decoding ANSI bytes.
     
    diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md
    index b70a46830f..d1d4a6ca85 100644
    --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md
    +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md
    @@ -33,7 +33,7 @@ TUI 覆盖分为四个互补层次:
     
     ### 语义终端投影
     
    -包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀。
    +包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定。流式输出检查点会冻结 loader 的 interval,同时保留跨过一次动画 tick 的真实墙钟等待,从而固定语义状态,而非调度器碰巧渲染出的某个加载动画字形。
     
     每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。
     
    diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts
    index 6ba3a95757..8c1c58f319 100644
    --- a/packages/pty/pty-local/tests/local.spec.ts
    +++ b/packages/pty/pty-local/tests/local.spec.ts
    @@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent {
       }
     }
     
    -async function harness(mode: 'danger-full-access' | 'workspace-write') {
    +async function harness(
    +  mode: 'danger-full-access' | 'workspace-write',
    +  timing: { idleSilenceMs?: number; timeoutMs?: number } = {},
    +) {
       const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
       roots.push(root)
       const ctx = new Context()
    @@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
       const fiber = await ctx.plugin(ptyLocal, {
         pollIntervalMs: 10,
         exactProbeAfterMs: 20,
    -    idleSilenceMs: 250,
    -    timeoutMs: 2000,
    +    idleSilenceMs: timing.idleSilenceMs ?? 250,
    +    timeoutMs: timing.timeoutMs ?? 2_000,
         disposeGraceMs: 500,
         scrollbackLines: 100,
         scrollbackMaxBytes: 32_768,
    @@ -63,8 +66,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
       return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
     }
     
    -async function waitForOutput(operation: PtySendOperation, expected: string): Promise {
    -  const deadline = Date.now() + 2_000
    +async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise {
    +  const deadline = Date.now() + timeoutMs
       let output = ''
       while (!output.includes(expected) && Date.now() < deadline) {
         output += operation.readOutput().delta
    @@ -131,20 +134,25 @@ describe('pty-local real shell', () => {
         expect(() => process.kill(pid, 0)).toThrow()
       }, 10_000)
     
    -  it('cancels a raw-mode foreground process with a real SIGINT', async () => {
    -    const { ctx, agent } = await harness('danger-full-access')
    +  it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
    +    const { ctx, agent } = await harness('danger-full-access', {
    +      idleSilenceMs: 10_000,
    +      timeoutMs: 15_000,
    +    })
         const created = await ctx.pty.spawn(agent, { type: 'shell' })
         const controller = new AbortController()
         const ready = 'RAW_READY'
    +    // Delay readiness beyond the shared harness's short send bound so this
    +    // process test owns enough slack for loaded macOS startup and shell echo.
         // The interactive shell echoes the command, so only child output may contain the readiness marker.
    -    const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_" + "READY", flush=True); time.sleep(60)\''
    +    const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); time.sleep(2.1); print("RAW_" + "READY", flush=True); time.sleep(60)\''
         expect(command).not.toContain(ready)
         const foreground = ctx.pty.startSend(agent, created.sessionId, {
           text: command,
           submit: true,
           signal: controller.signal,
         })
    -    await waitForOutput(foreground, ready)
    +    await waitForOutput(foreground, ready, 15_000)
         controller.abort()
         const result = await foreground.done
         expect(result.waitReason).toBe('stdin_read')
    @@ -155,5 +163,5 @@ describe('pty-local real shell', () => {
         expect(after.viewport).toContain('AFTER_SIGINT')
         expect(after.waitReason).toBe('stdin_read')
         await ctx.pty.kill(agent, created.sessionId)
    -  }, 10_000)
    +  }, 20_000)
     })
    diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts
    index 1dc6ffa7cd..3bc5adc366 100644
    --- a/packages/ui/tui/tests/tui.snapshot.ts
    +++ b/packages/ui/tui/tests/tui.snapshot.ts
    @@ -228,33 +228,45 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7
     describe('TUI terminal-state snapshots', () => {
       it('pins an in-flight reasoning and Markdown stream', async () => {
         const harness = await setupSnapshot()
    -    await renderAfter(harness, () => {
    -      harness.agent.status = 'running'
    -      harness.ctx.emit('agent/status', harness.agent, 'running')
    -      appendUser(harness.session, 'Show the live update.')
    -      harness.session.append('assistant/chunk', {
    -        turn: 1,
    -        step: 1,
    -        chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
    +    // Freeze the loader's first animation interval so this semantic snapshot
    +    // cannot select a different spinner frame under scheduler contention.
    +    const frozenLoaderTimer = setInterval(() => {}, 60_000)
    +    const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer)
    +    try {
    +      await renderAfter(harness, () => {
    +        harness.agent.status = 'running'
    +        harness.ctx.emit('agent/status', harness.agent, 'running')
    +        appendUser(harness.session, 'Show the live update.')
    +        harness.session.append('assistant/chunk', {
    +          turn: 1,
    +          step: 1,
    +          chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
    +        })
    +        harness.session.append('assistant/chunk', {
    +          turn: 1,
    +          step: 1,
    +          chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
    +        })
    +        harness.session.append('assistant/chunk', {
    +          turn: 1,
    +          step: 1,
    +          chunk: { type: 'block-start', index: 1, blockType: 'text' },
    +        })
    +        harness.session.append('assistant/chunk', {
    +          turn: 1,
    +          step: 1,
    +          chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
    +        })
           })
    -      harness.session.append('assistant/chunk', {
    -        turn: 1,
    -        step: 1,
    -        chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
    -      })
    -      harness.session.append('assistant/chunk', {
    -        turn: 1,
    -        step: 1,
    -        chunk: { type: 'block-start', index: 1, blockType: 'text' },
    -      })
    -      harness.session.append('assistant/chunk', {
    -        turn: 1,
    -        step: 1,
    -        chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
    -      })
    -    })
    -    await checkpoint('conversation-streaming', harness.terminal)
    -    await disposeSnapshot(harness)
    +      const loaderIntervalMs = intervals.mock.calls[0]?.[1]
    +      if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval')
    +      await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5))
    +      await checkpoint('conversation-streaming', harness.terminal)
    +    } finally {
    +      intervals.mockRestore()
    +      clearInterval(frozenLoaderTimer)
    +      await disposeSnapshot(harness)
    +    }
       })
     
       it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {
    
    From caabf8f671d194194b4d8b876566b847d2f73ddf Mon Sep 17 00:00:00 2001
    From: Chinesezjc 
    Date: Mon, 27 Jul 2026 15:36:11 +0800
    Subject: [PATCH 38/56] ci: dependabot stays hosted under failover; runbook
     matches shipped bounds
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    - All four failover selectors (three workers + the verdict job) and the
      paired env/cache expressions now exclude dependabot[bot]: under
      failover, dependency-supplied code keeps queueing for the hosted pool
      instead of executing on the persistent VM. A delayed Dependabot PR
      during an outage is an acceptable cost; dependency code on the
      privileged host is not.
    - Runbook (both languages): records the shipped failover bounds
      (coverage 8, snapshots 12, sized for six instances) and documents
      that the verdict job follows the selector too — operators previously
      had no explanation for a verdict queued after all workers passed.
    - Local static gate green: 32 passed, 0 failed (translation pairing
      519 pairs consistent).
    ---
     .../2026-07-26-ci-failover-runbook.i18n.yaml       |  4 ++--
     .../process/2026-07-26-ci-failover-runbook.md      |  6 +++---
     .../process/2026-07-26-ci-failover-runbook.zh.md   |  6 +++---
     .github/workflows/ci.yml                           | 14 +++++++++-----
     4 files changed, 17 insertions(+), 13 deletions(-)
    
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    index efb5fdd1cc..26f7f23f85 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    -2026-07-26-ci-failover-runbook.md: 0bce83e0f9c842fa3dd73ae9c0a3eefc0975cdae
    -2026-07-26-ci-failover-runbook.zh.md: 4bc6c67bab754ad3f0127557b0d5e04f7934c8a2
    +2026-07-26-ci-failover-runbook.md: ab1a727caa045d2074a9c577416f96f45efcd0aa
    +2026-07-26-ci-failover-runbook.zh.md: 5dfaca0c1c0f443307bea28bb6544385ebb68bb7
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    index 0bce83e0f9..ab1a727caa 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    @@ -6,11 +6,11 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md)
     
     ## Problem
     
    -The three required Linux jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch a repository admin can throw without merging anything.
    +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) and the required verdict job that aggregates them (`all checks passed`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch a repository admin can throw without merging anything.
     
     ## Decision
     
    -Each of the three required Linux jobs resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by a repository admin, all three retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is admin-only repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push.
    +Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by a repository admin, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is admin-only repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push.
     
     ### What the in-house pool is
     
    @@ -20,7 +20,7 @@ Each of the three required Linux jobs resolves its runner pool through the `DSH_
     
     1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`.
     2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
    -3. That is the entire switch. Under failover the workflow also, automatically: halves `DSH_COVERAGE_MAX_WORKERS` to 12 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 16 (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs).
    +3. That is the entire switch. Under failover the workflow also, automatically: drops `DSH_COVERAGE_MAX_WORKERS` to 8 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 (sized for six always-on instances: worst case 6 × 8 = 48 coverage workers on the 64-core VM) (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs).
     
     ### Capacity during failover
     
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    index 4bc6c67bab..5dfaca0c1c 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    @@ -6,11 +6,11 @@ Status: implemented
     
     ## 问题
     
    -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个仓库管理员无需合并任何代码即可触发的开关。
    +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)以及聚合它们的必需判定作业(`all checks passed`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个仓库管理员无需合并任何代码即可触发的开关。
     
     ## 决策
     
    -三个必需的 Linux 作业各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由仓库管理员设为 `selfhosted` 时,三者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是仅限管理员的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。
    +三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由仓库管理员设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是仅限管理员的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。
     
     ### 自有池是什么
     
    @@ -20,7 +20,7 @@ Status: implemented
     
     1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。
     2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
    -3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 12、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 16(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。
    +3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏 6 × 8 = 48 个覆盖率工作进程对 64 核)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。
     
     ### 切换期间的容量
     
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index df3d386e39..8306b7e034 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -44,6 +44,7 @@ jobs:
         if: github.event_name == 'pull_request'
         runs-on: >-
           ${{ vars.DSH_CI_FAILOVER == 'selfhosted'
    +          && github.event.pull_request.user.login != 'dependabot[bot]'
               && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
               || 'dsh-enterprise-ubuntu-latest-32core-test' }}
         name: node 24 / static
    @@ -60,7 +61,7 @@ jobs:
           # compression and upload on the paid latency-critical path. Skipped
           # under failover — see the coverage lane's identical rationale.
           - uses: actions/cache/restore@v4
    -        if: vars.DSH_CI_FAILOVER != 'selfhosted'
    +        if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
             with:
               path: /home/runner/.local/share/pnpm/store/v11
               key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
    @@ -98,6 +99,7 @@ jobs:
         if: github.event_name == 'pull_request'
         runs-on: >-
           ${{ vars.DSH_CI_FAILOVER == 'selfhosted'
    +          && github.event.pull_request.user.login != 'dependabot[bot]'
               && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
               || 'dsh-enterprise-ubuntu-24-04-32core-test' }}
         name: node 24 / coverage
    @@ -107,7 +109,7 @@ jobs:
           # across six always-on runner instances, and the timing-sensitive
           # process suites have documented aggregate-contention failures.
           # 8 × 6 instances = 48 workers worst case on 64 cores.
    -      DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '8' || '24' }}
    +      DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '24' }}
           DSH_GATE_CONCURRENCY: '8'
         steps:
           - uses: actions/checkout@v6
    @@ -118,7 +120,7 @@ jobs:
           # serves warm installs directly, and this hosted-path restore would
           # spend ~52 s pulling ~180 MB into a path pnpm never reads there.
           - uses: actions/cache/restore@v4
    -        if: vars.DSH_CI_FAILOVER != 'selfhosted'
    +        if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
             with:
               path: /home/runner/.local/share/pnpm/store/v11
               key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
    @@ -151,6 +153,7 @@ jobs:
         if: github.event_name == 'pull_request'
         runs-on: >-
           ${{ vars.DSH_CI_FAILOVER == 'selfhosted'
    +          && github.event.pull_request.user.login != 'dependabot[bot]'
               && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
               || 'dsh-enterprise-ubuntu-latest-32core-test' }}
         name: node 24 / snapshots and artifacts
    @@ -161,7 +164,7 @@ jobs:
           DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
           DSH_PUBLINT_CONCURRENCY: '8'
           # Failover halves snapshot concurrency for the shared 64-core VM.
    -      DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '12' || '32' }}
    +      DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }}
         steps:
           - uses: actions/checkout@v6
             with:
    @@ -177,7 +180,7 @@ jobs:
     
           # Skipped under failover — see the coverage lane's identical rationale.
           - uses: actions/cache/restore@v4
    -        if: vars.DSH_CI_FAILOVER != 'selfhosted'
    +        if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
             with:
               path: /home/runner/.local/share/pnpm/store/v11
               key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
    @@ -773,6 +776,7 @@ jobs:
         # queued forever on the failed pool.
         runs-on: >-
           ${{ vars.DSH_CI_FAILOVER == 'selfhosted'
    +          && github.event.pull_request.user.login != 'dependabot[bot]'
               && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
               || 'dsh-enterprise-ubuntu-latest-32core-test' }}
         needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows]
    
    From 0a7c8a284d53d20500232ef599114b1395144197 Mon Sep 17 00:00:00 2001
    From: imccyu <276526105+imccyu@users.noreply.github.com>
    Date: Mon, 27 Jul 2026 15:44:10 +0800
    Subject: [PATCH 39/56] chore
    
    ---
     packages/client/ui-conversation/src/client/queue/QueueDock.tsx | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    
    diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx
    index fcd7e75732..f5b5047c7b 100644
    --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx
    +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx
    @@ -30,7 +30,7 @@ export function QueueDock({ useSession }: QueueDockProps) {
     }
     
     /**
    - * The dock entry as a plain registrant plugin (bash-sample posture).
    + * The dock entry as a plain registrant plugin (bash posture).
      * `inject: ['conversation']` is the ordering seam: the conversation service
      * mounts after ui-conversation's slot registrations, so the
      * 'conversation.input.dock' declaration is on the ledger by then.
    
    From d606ab6877a4516a422ab45e4160873843aa9a55 Mon Sep 17 00:00:00 2001
    From: Chinesezjc 
    Date: Mon, 27 Jul 2026 15:47:19 +0800
    Subject: [PATCH 40/56] =?UTF-8?q?ci:=20standby=20archive=20base=20ref;=20r?=
     =?UTF-8?q?unbook=20=E2=80=94=20dependabot=20exception,=20clean=20bootstra?=
     =?UTF-8?q?p,=20variable=20trust=20model?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    - serial-linux-selfhosted now fetches depth 2 and passes
      DSH_ARCHIVE_BASE_REF=github.event.before, running the same
      frozen-archive comparison as serial-linux instead of diffing the
      new manifest against itself.
    - Runbook (both languages): documents the deliberate dependabot
      exception (queued-on-hosted during failover is expected, not a
      failed switch); corrects the emergency-capacity bootstrap to
      exclude .runner/.credentials when cloning a runner directory; and
      replaces the 'admin-only' variable claim with the accurate
      trust-model statement — repository variables are writer-manageable,
      which in this private fork-disabled repo with an all-workflows
      runner group is routing among members, not an escalation.
    Static gate green locally: 32 passed, 0 failed.
    ---
     .../process/2026-07-26-ci-failover-runbook.i18n.yaml      | 4 ++--
     .../implemented/process/2026-07-26-ci-failover-runbook.md | 8 ++++++--
     .../process/2026-07-26-ci-failover-runbook.zh.md          | 8 ++++++--
     .github/workflows/ci.yml                                  | 6 ++++++
     4 files changed, 20 insertions(+), 6 deletions(-)
    
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    index 26f7f23f85..7b8d08befe 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    -2026-07-26-ci-failover-runbook.md: ab1a727caa045d2074a9c577416f96f45efcd0aa
    -2026-07-26-ci-failover-runbook.zh.md: 5dfaca0c1c0f443307bea28bb6544385ebb68bb7
    +2026-07-26-ci-failover-runbook.md: 55c1350593562d62463e751451d50a79cf45a1d6
    +2026-07-26-ci-failover-runbook.zh.md: 13977b78244440a23722d089849ea7ff6b751aea
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    index ab1a727caa..55c1350593 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    @@ -22,9 +22,13 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver
     2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
     3. That is the entire switch. Under failover the workflow also, automatically: drops `DSH_COVERAGE_MAX_WORKERS` to 8 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 (sized for six always-on instances: worst case 6 × 8 = 48 coverage workers on the 64-core VM) (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs).
     
    -### Capacity during failover
    +#**Dependabot exception.** All four selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VM. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers.
     
    -Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner) — cloning an existing runner directory and running `config.sh` takes about a minute per instance.
    +**Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so the switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner group admits all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VM by pushing a branch workflow. The boundary against untrusted code is repository membership; the variable only routes work for members.
    +
    +## Capacity during failover
    +
    +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh`; copying `.runner`/`.credentials` verbatim makes `config.sh` refuse with "already configured". About a minute per instance.
     
     
     ### Switch back
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    index 5dfaca0c1c..13977b7824 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    @@ -22,9 +22,13 @@ Status: implemented
     2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
     3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏 6 × 8 = 48 个覆盖率工作进程对 64 核)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。
     
    -### 切换期间的容量
    +#**Dependabot 例外。**四个选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖方提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。
     
    -6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例——复制现有 runner 目录再跑 `config.sh`,每个约一分钟。
    +**谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此该开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成越权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。
    +
    +## 切换期间的容量
    +
    +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`;原样拷贝 `.runner`/`.credentials` 会使 `config.sh` 以 "already configured" 拒绝。每个约一分钟。
     
     
     ### 切回
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index 8306b7e034..8c3b854ae6 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -423,7 +423,12 @@ jobs:
         name: serial / linux (self-hosted standby)
         runs-on: [self-hosted, linux, x64, vm-backup]
         steps:
    +      # fetch-depth 2 + DSH_ARCHIVE_BASE_REF below: same frozen-archive
    +      # comparison as serial-linux — without the prior commit the archive
    +      # verifier defaults to HEAD and compares the new manifest with itself.
           - uses: actions/checkout@v6
    +        with:
    +          fetch-depth: 2
     
           - uses: actions/setup-node@v6
             with:
    @@ -440,6 +445,7 @@ jobs:
     
           - name: Run complete unsharded primary Node CI serially
             env:
    +          DSH_ARCHIVE_BASE_REF: ${{ github.event.before }}
               DSH_COVERAGE_MAX_WORKERS: '1'
               DSH_E2E_MAX_WORKERS: '1'
               DSH_ESLINT_CACHE: '1'
    
    From 228d503230f879afea72b4c455231f7b3df37241 Mon Sep 17 00:00:00 2001
    From: imccyu <276526105+imccyu@users.noreply.github.com>
    Date: Mon, 27 Jul 2026 15:49:37 +0800
    Subject: [PATCH 41/56] fix: code-mode fixture snapshot
    
    ---
     apps/web/tests/code-mode-fixture.snapshot.ts | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    
    diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts
    index a727844b1b..f53777fff8 100644
    --- a/apps/web/tests/code-mode-fixture.snapshot.ts
    +++ b/apps/web/tests/code-mode-fixture.snapshot.ts
    @@ -144,7 +144,7 @@ it('renders the fixture run_code turn: code parent row, nested sub-rows, error s
           "errorSubRow": true,
           "parentRow": "CodeRead the notes files and summarize",
           "subRows": [
    -        "$List notes",
    +        "BashList notes",
             "Readnotes/demo.txt",
             "Readnotes/missing.txt",
           ],
    
    From 4701373fc2cf87e14fb53885f9fa434b54ca0e20 Mon Sep 17 00:00:00 2001
    From: NI0317 
    Date: Mon, 27 Jul 2026 15:52:35 +0800
    Subject: [PATCH 42/56] fix(workspace): remove transient duplicate warning
    
    ---
     ...-workspace-registration-deletion.i18n.yaml |  4 +-
     ...6-07-27-workspace-registration-deletion.md |  4 +-
     ...7-27-workspace-registration-deletion.zh.md |  4 +-
     apps/web/tests/workspace-management.e2e.ts    | 88 +++++++++++++++++++
     .../runtime/src/client/workspaces/manager.ts  | 15 +++-
     .../src/client/WorkspaceBrowser.tsx           | 15 +++-
     .../src/client/WorkspacePicker.tsx            |  2 +-
     .../tests/workspace-browser.spec.tsx          |  7 +-
     .../tests/workspace-picker.spec.tsx           | 33 +++++--
     9 files changed, 155 insertions(+), 17 deletions(-)
    
    diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml
    index 93c78373c6..d576fb10e5 100644
    --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml
    +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
    -2026-07-27-workspace-registration-deletion.md: 58ae5c4bef2cf1cb0a0158eda5eb37daf2e9703d
    -2026-07-27-workspace-registration-deletion.zh.md: 7a79a1ccc53a0d4fd7e5ab453239ade955313c6e
    +2026-07-27-workspace-registration-deletion.md: 8168b0832ca39e6023f6981815ffe758b5695361
    +2026-07-27-workspace-registration-deletion.zh.md: b0df6982ac81426a5b0ce2f0e2b0e744212e3f5b
    diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
    index 58ae5c4bef..8168b0832c 100644
    --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
    +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
    @@ -28,6 +28,8 @@ Create and delete write a durable `pendingMutation` before their record/order pa
     
     `WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta.
     
    +The delete confirmation remains pending until the React Workspace projection has committed the removed id, so the next create gesture cannot observe one stale list frame. During create, duplicate-name validation is suppressed while the request is pending because the committed `host/workspace-changed` frame may publish the newly created Workspace before its unary response; after failure returns the form to editing, validation uses the latest list again.
    +
     ## Confirmation interaction
     
     The existing Workspace row menu opens a shared `Modal` before deletion. The text states all three consequences: the Workspace leaves the list, the folder and session logs remain, and its Sessions appear under Ungrouped. While the request is pending, the confirm and Cancel controls are disabled, duplicate confirmation is ignored, and Escape or Close cannot dismiss the operation. Failure keeps the Modal open with the error; Cancel, Escape, and Close before submission never delete.
    @@ -48,7 +50,7 @@ The menu, Modal, and buttons retain their existing structure and design tokens.
     
     ## Verification
     
    -Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close.
    +Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, projection-settled closing, pending-state duplicate suppression, success-frame-before-unary ordering, failure, Cancel, Escape, and Close. The browser scenario observes every transient alert, slot error, console error, and page error while reusing a deleted title for a different directory.
     
     The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload.
     
    diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md
    index 7a79a1ccc5..b0df6982ac 100644
    --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md
    +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md
    @@ -28,6 +28,8 @@ Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendin
     
     `WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。
     
    +删除确认框会保持待处理,直到 React Workspace 投影已经提交目标 id 的移除,因此下一次创建操作不会读到一帧陈旧列表。创建请求进行中会暂停重复名称校验,因为已提交的 `host/workspace-changed` 帧可能先于一元响应发布刚创建的 Workspace;如果请求失败并让表单回到可编辑状态,系统会重新使用最新列表执行校验。
    +
     ## 确认交互
     
     现有 Workspace 行菜单会在删除前打开共享 `Modal`。文案明确说明三项后果:Workspace 会从列表中移除,文件夹和会话日志会保留,相关会话会出现在 Ungrouped 下。请求待处理期间,确认与 Cancel 控件均被禁用,重复确认会被忽略,Escape 或 Close 也无法关闭此次操作。失败时 `Modal` 保持打开并显示错误;提交前使用 Cancel、Escape 或 Close 绝不会触发删除。
    @@ -48,7 +50,7 @@ Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendin
     
     ## Verification
     
    -Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。
    +Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、投影稳定后关闭、待处理状态下抑制重复提交、成功帧先于一元响应、失败、Cancel、Escape 与 Close。浏览器场景会在为不同目录复用已删除名称时,观测每一次瞬时 alert、slot error、console error 与 page error。
     
     组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。
     
    diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts
    index 3239dcfc12..98a2338064 100644
    --- a/apps/web/tests/workspace-management.e2e.ts
    +++ b/apps/web/tests/workspace-management.e2e.ts
    @@ -108,6 +108,31 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
     
       it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => {
         onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete'))
    +    const slotConsoleErrors: string[] = []
    +    const transientSlotErrors: string[] = []
    +    page.on('console', (message) => {
    +      if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
    +        slotConsoleErrors.push(message.text())
    +      }
    +    })
    +    await page.exposeFunction('recordDshSlotError', (key: string) => {
    +      if (!transientSlotErrors.includes(key)) transientSlotErrors.push(key)
    +    })
    +    await page.evaluate(() => {
    +      const target = window as unknown as { recordDshSlotError(key: string): Promise }
    +      const seen = new Set()
    +      const collect = (): void => {
    +        for (const node of document.querySelectorAll('[data-slot-error]')) {
    +          const key = node.dataset.slotError ?? ''
    +          if (!seen.has(key)) {
    +            seen.add(key)
    +            void target.recordDshSlotError(key)
    +          }
    +        }
    +      }
    +      new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true })
    +      collect()
    +    })
         // Register the scaffold's existing project directory through the real UI.
         await page.getByRole('button', { name: 'Create workspace' }).click()
         await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
    @@ -212,6 +237,69 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
         await stat(logLocation.path)
         expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
     
    +    expect(transientSlotErrors).toEqual([])
    +    expect(slotConsoleErrors).toEqual([])
    +    expect(tripwire.pageErrors).toEqual([])
    +  }, 90_000)
    +
    +  it('reuses a deleted title for a different new directory without any transient error surface', async () => {
    +    onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-reuse-title'))
    +    const title = 'same-name'
    +    const oldPath = join(scaffold.workspaceCwd, 'adopted', title)
    +    await mkdir(oldPath, { recursive: true })
    +    const transientErrors: string[] = []
    +    const consoleErrors: string[] = []
    +    page.on('console', (message) => {
    +      if (message.type() === 'error') consoleErrors.push(message.text())
    +    })
    +    await page.exposeFunction('recordDshTransientWorkspaceError', (message: string) => {
    +      if (!transientErrors.includes(message)) transientErrors.push(message)
    +    })
    +    await page.evaluate(() => {
    +      const target = window as unknown as {
    +        recordDshTransientWorkspaceError(message: string): Promise
    +      }
    +      const collect = (): void => {
    +        for (const node of document.querySelectorAll('[data-slot-error], [role="alert"]')) {
    +          const message = node.dataset.slotError ?? node.textContent?.trim() ?? ''
    +          if (message !== '') void target.recordDshTransientWorkspaceError(message)
    +        }
    +      }
    +      new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true })
    +      collect()
    +    })
    +
    +    await page.getByRole('button', { name: 'Create workspace' }).click()
    +    await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
    +    await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
    +    const adopt = page.getByRole('dialog', { name: 'Use an existing folder' })
    +    await adopt.getByLabel('Existing folder path').fill(oldPath)
    +    await adopt.getByRole('button', { name: 'Use folder' }).click()
    +    await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0)
    +    const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath)
    +    if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered')
    +
    +    const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first()
    +    await oldRow.hover()
    +    await page.getByRole('button', { name: `Workspace actions for ${title}` }).click()
    +    await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
    +    await page.getByRole('dialog', { name: 'Delete workspace' })
    +      .getByRole('button', { name: 'Delete workspace' }).click()
    +    await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined()
    +
    +    await page.getByRole('button', { name: 'Create workspace' }).click()
    +    await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
    +    await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
    +    const create = page.getByRole('dialog', { name: 'Create a new workspace' })
    +    await create.getByLabel('New workspace name').fill(title)
    +    await create.getByRole('button', { name: 'Create workspace' }).click()
    +    await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0)
    +    const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title)
    +    expect(fresh?.id).toBeDefined()
    +    expect(fresh?.id).not.toBe(oldWorkspace.id)
    +    expect(fresh?.path).toBe(join(scaffold.workspaceCwd, title))
    +    expect(transientErrors).toEqual([])
    +    expect(consoleErrors).toEqual([])
         expect(tripwire.pageErrors).toEqual([])
       }, 90_000)
     
    diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts
    index 7179ed9eb9..ce4198cd01 100644
    --- a/packages/client/runtime/src/client/workspaces/manager.ts
    +++ b/packages/client/runtime/src/client/workspaces/manager.ts
    @@ -133,7 +133,7 @@ export class WorkspaceManager {
        */
       async delete(workspaceId: WorkspaceId): Promise> {
         const { result } = await this.api.workspace.delete({ workspaceId })
    -    if (result.ok) this.remove(workspaceId)
    +    if (result.ok) this.remove(workspaceId, true)
         return result
       }
     
    @@ -224,14 +224,21 @@ export class WorkspaceManager {
       }
     
       /** Remove one id idempotently and retain a tombstone against late echoes. */
    -  private remove(workspaceId: WorkspaceId): void {
    +  private remove(workspaceId: WorkspaceId, direct = false): void {
         this.refreshFrames?.push({ type: 'remove', workspaceId })
         this.removedIds.add(workspaceId)
         const items = this.items.filter(item =>
           item.getSnapshot().view?.workspaceId !== workspaceId)
    -    if (items.length === this.items.length) return
    +    if (items.length === this.items.length) {
    +      // The Host frame may have removed the row first but left its batched
    +      // notification pending. A successful unary echo still flushes that
    +      // committed state before the user action resolves.
    +      if (direct) this.notifier.notifyNow()
    +      return
    +    }
         this.items = items
    -    this.notifier.markDirty()
    +    if (direct) this.notifier.notifyNow()
    +    else this.notifier.markDirty()
       }
     
       private installViews(views: readonly WorkspaceView[]): void {
    diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
    index 0090164928..c56de93c56 100644
    --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
    +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
    @@ -307,7 +307,15 @@ export function WorkspaceBrowser({
       // unmount that row without tearing down the in-flight confirmation state.
       const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
       const [deleting, setDeleting] = useState(false)
    +  const [deleteCommittedId, setDeleteCommittedId] = useState(null)
       const [deleteError, setDeleteError] = useState(null)
    +  useEffect(() => {
    +    if (deleteCommittedId === null
    +      || workspaces.some(workspace => workspace.workspaceId === deleteCommittedId)) return
    +    setDeleting(false)
    +    setDeleteCommittedId(null)
    +    setDeleteTarget(null)
    +  }, [deleteCommittedId, workspaces])
       const closeDelete = () => {
         if (deleting) return
         setDeleteTarget(null)
    @@ -317,10 +325,13 @@ export function WorkspaceBrowser({
         /* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */
         if (deleting || deleteTarget === null) return
         setDeleting(true)
    +    setDeleteCommittedId(null)
         setDeleteError(null)
         deleteWorkspace(deleteTarget.workspaceId).then(() => {
    -      setDeleting(false)
    -      setDeleteTarget(null)
    +      // Keep the confirmation pending until this component has rendered the
    +      // committed list projection without the deleted id. Closing earlier
    +      // exposes one stale React frame to the next Create Workspace gesture.
    +      setDeleteCommittedId(deleteTarget.workspaceId)
         }).catch((reason: unknown) => {
           setDeleting(false)
           setDeleteError(reason instanceof Error ? reason.message : String(reason))
    diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx
    index 2be39875bc..99ed2831a6 100644
    --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx
    +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx
    @@ -60,7 +60,7 @@ export function WorkspaceCreateFlow({
       const [creating, setCreating] = useState(false)
       const [modalError, setModalError] = useState(null)
       const normalizedWorkspaceName = workspaceName.trim()
    -  const duplicateWorkspaceName = normalizedWorkspaceName !== ''
    +  const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
         && workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
     
       const items: MenuEntry[] = [
    diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx
    index 1dbe895b74..33abdc1231 100644
    --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx
    +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx
    @@ -461,7 +461,7 @@ describe('WorkspaceBrowser', () => {
       it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => {
         let resolveDelete!: () => void
         const deleteWorkspace = vi.fn(() => new Promise((resolve) => { resolveDelete = resolve }))
    -    mount({
    +    const browser = mount({
           useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])),
           deleteWorkspace,
         })
    @@ -484,6 +484,11 @@ describe('WorkspaceBrowser', () => {
         fireEvent.click(screen.getByRole('button', { name: 'Close' }))
         expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
         await act(async () => { resolveDelete() })
    +    // RPC success alone does not close: the component waits until its
    +    // useWorkspaces projection has committed the removal, preventing a stale
    +    // duplicate-name frame from leaking into the next create gesture.
    +    expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
    +    rerender(browser, { useWorkspaces: hook(workspaceState([])) })
         expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
       })
     
    diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx
    index d487fae5f8..a5510178e7 100644
    --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx
    +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx
    @@ -35,18 +35,25 @@ function anchor(): { current: HTMLElement } {
     function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
       const onPick = vi.fn()
       const onClose = vi.fn()
    -  const view = render(
    +  const anchorRef = anchor()
    +  const renderPicker = (nextItems: readonly WorkspaceView[]) => (
         ,
    +    />
       )
    -  return { view, onPick, onClose, createWorkspace }
    +  const view = render(
    +    renderPicker(items),
    +  )
    +  return {
    +    view, onPick, onClose, createWorkspace,
    +    rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
    +  }
     }
     
     function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
    @@ -106,6 +113,22 @@ describe('WorkspacePicker', () => {
         expect(b.createWorkspace).not.toHaveBeenCalled()
       })
     
    +  it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => {
    +    let resolve!: (workspace: WorkspaceView) => void
    +    const pending = new Promise((settle) => { resolve = settle })
    +    const created = workspace('fresh', 'same-name')
    +    const b = mount([], vi.fn(() => pending))
    +    chooseCreateItem('Create a new workspace')
    +    fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
    +    fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
    +
    +    b.rerenderItems([created])
    +    expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
    +    expect(screen.queryByRole('alert')).toBeNull()
    +    await act(async () => { resolve(created); await pending })
    +    expect(b.onPick).toHaveBeenCalledWith(created.workspaceId)
    +  })
    +
       it('exposes creation phase and error text while retaining the modal for retry', async () => {
         let reject!: (reason: unknown) => void
         const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise })
    
    From e2eca69e9c0ba88f591afe84727b4634d596c3b3 Mon Sep 17 00:00:00 2001
    From: Chinesezjc 
    Date: Mon, 27 Jul 2026 15:54:59 +0800
    Subject: [PATCH 43/56] docs(ci): writer-level trust boundary stated
     everywhere; serial note counts four references
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    - Sweep every remaining 'admin-only' claim (workflow comments, runbook
      lines 13/40, topology note, all zh pairs): the variable is
      writer-manageable, and the boundary against untrusted code is
      repository membership (private, forking disabled, Dependabot
      excluded) — stated identically at every site instead of only in the
      'who can flip' paragraph.
    - Serial cross-platform reference note (both languages): master now
      runs four references — the three hosted OS legs plus the self-hosted
      standby drill, linked to the failover runbook.
    Static gate green locally: 32 passed, 0 failed.
    ---
     ...2026-07-21-serial-cross-platform-ci-reference.i18n.yaml | 6 +++---
     .../2026-07-21-serial-cross-platform-ci-reference.md       | 6 +++---
     .../2026-07-21-serial-cross-platform-ci-reference.zh.md    | 6 +++---
     ...26-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++--
     .../2026-07-22-evidence-based-larger-hosted-runners.md     | 2 +-
     .../2026-07-22-evidence-based-larger-hosted-runners.zh.md  | 2 +-
     .../process/2026-07-26-ci-failover-runbook.i18n.yaml       | 4 ++--
     .../implemented/process/2026-07-26-ci-failover-runbook.md  | 4 ++--
     .../process/2026-07-26-ci-failover-runbook.zh.md           | 4 ++--
     .github/workflows/ci.yml                                   | 7 ++++---
     10 files changed, 23 insertions(+), 22 deletions(-)
    
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    index 17edb300cc..50ac9c830b 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write
    -2026-07-21-serial-cross-platform-ci-reference.md: 5433d2c51831ce61d06a16ee0b0ed982911f9218
    -2026-07-21-serial-cross-platform-ci-reference.zh.md: 041d53d13e14354c995e4b65defce94a97646b0a
    +#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    +2026-07-21-serial-cross-platform-ci-reference.md: 3e3d3ed06a16baf81b940b50c3d3deb75b7d8894
    +2026-07-21-serial-cross-platform-ci-reference.zh.md: e05f92c05ab66d5a444f29186c605b4609d36110
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    index 5433d2c518..3e3d3ed06a 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    @@ -14,15 +14,15 @@ Reviewers also need a direct answer to a simpler question: what happens when the
     
     ## Decision
     
    -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
    +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
     
    -Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
    +Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
     
     Platform ownership remains explicit inside that complete aggregate. `pty-local` supports Linux and macOS and therefore owns its unit and per-file coverage contract on POSIX rather than loading a backend that rejects `win32`; the Windows run still executes every portable package. Portable fixtures derive native paths through `node:path`, compare canonical identities with the same native realpath implementation as production, and use filenames legal on every host. ACP snapshot runs also pass both JavaScript and native realpath spellings of their generated cwd to the normalizer, which replaces aliases longest-first so Windows short and long paths cannot churn shared fixtures.
     
     The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install.
     
    -Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
    +Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
     
     The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
     
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    index 041d53d13e..e05f92c05a 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    @@ -14,15 +14,15 @@ Status: implemented
     
     ## 决策
     
    -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
    +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
     
    -每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
    +每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
     
     该完整聚合流程仍明确划分平台归属。`pty-local` 支持 Linux 与 macOS,因此其单元测试和逐文件覆盖率契约由 POSIX 平台负责,而不会在 Windows 上加载一个明确拒绝 `win32` 的后端;Windows 仍会执行所有可移植包(package)。可移植 fixture(测试前置数据)通过 `node:path` 派生原生路径,使用与生产代码相同的原生 realpath 实现比较规范化后的路径标识,并采用所有宿主机均允许的文件名。ACP(Agent Client Protocol)快照运行还会把生成的 cwd 分别通过 realpath 的 JavaScript 实现与原生实现得到的两种表示一并传给规范化器;规范化器按长度从长到短替换这些别名,避免 Windows 的短路径与长路径表示差异导致共享 fixture 反复变化。
     
     macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。
     
    -master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
    +master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
     
     可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
     
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    index 99cabc76bb..f65ebb1ba4 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    -2026-07-22-evidence-based-larger-hosted-runners.md: 5b399be5571ddaf1f775ba43a2233198b8e09b18
    -2026-07-22-evidence-based-larger-hosted-runners.zh.md: f77516e2375bfc0557679d05fd275bd9cee7d8eb
    +2026-07-22-evidence-based-larger-hosted-runners.md: 180cc03ad091b2e9e96a86311515250f92065c6b
    +2026-07-22-evidence-based-larger-hosted-runners.zh.md: b81f67805fd543e81ede02ceeac2dda1831f4cef
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    index 5b399be557..180cc03ad0 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    @@ -52,7 +52,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two
     
     Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch.
     
    -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled.
    +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled.
     
     ## Alternatives considered
     
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    index f77516e237..b81f67805f 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
     
     只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。
     
    -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。
    +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。
     
     ## 曾考虑的替代方案
     
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    index 7b8d08befe..de9ba10469 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    -2026-07-26-ci-failover-runbook.md: 55c1350593562d62463e751451d50a79cf45a1d6
    -2026-07-26-ci-failover-runbook.zh.md: 13977b78244440a23722d089849ea7ff6b751aea
    +2026-07-26-ci-failover-runbook.md: 05014454fa3e38045b89a857c346db0f897ab5a6
    +2026-07-26-ci-failover-runbook.zh.md: e106a0de40799ca1c218217ea66c24068697dc53
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    index 55c1350593..05014454fa 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    @@ -10,7 +10,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym
     
     ## Decision
     
    -Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by a repository admin, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is admin-only repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push.
    +Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push.
     
     ### What the in-house pool is
     
    @@ -37,7 +37,7 @@ Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhos
     
     ### Trust boundary
     
    -The variable is repository-admin-only state: a pull request can neither set it nor read a different value into effect, and the expressions live in the base branch's workflow definition. This failover path therefore adds no PR-editable route to the self-hosted pool. Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism.
    +The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism.
     
     ## Alternatives considered
     
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    index 13977b7824..e106a0de40 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    @@ -10,7 +10,7 @@ Status: implemented
     
     ## 决策
     
    -三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由仓库管理员设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是仅限管理员的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。
    +三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。
     
     ### 自有池是什么
     
    @@ -37,7 +37,7 @@ Status: implemented
     
     ### 信任边界
     
    -该变量是仅限仓库管理员的状态:拉取请求既不能设置它,也不能让不同的值生效,且表达式存在于基线分支的工作流定义中。因此这条故障切换路径没有增加任何可由 PR 编辑的自托管池访问途径。运行器侧的强制约束——通过组织级 runner group 把这批运行器限定到 master 引用的工作流——另行跟踪,与本机制互补。
    +该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。(运行器侧的组织级 runner group 约束另行跟踪,与本机制互补。)
     
     ## 曾考虑的替代方案
     
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index 8c3b854ae6..9fe393b93c 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -34,8 +34,9 @@ jobs:
       # FAILOVER: each Linux enterprise job resolves its pool through the
       # DSH_CI_FAILOVER repository variable. Unset (normal), the expressions
       # pick the hosted enterprise pools below. Setting the variable to
    -  # 'selfhosted' (repo Settings → Actions → Variables; admin-only, not
    -  # PR-editable, no merge required) retargets all three onto the in-house
    +  # 'selfhosted' (repo Settings → Actions → Variables; writer-manageable
    +  # repository state — not PR-editable, no merge required) retargets all
    +  # three onto the in-house
       # vm-backup pool and re-running the failed jobs is the entire switch —
       # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The
       # in-house pool's readiness is re-proven on every master push by the
    @@ -411,7 +412,7 @@ jobs:
       # Hot-standby drill for the in-house self-hosted pool: every master move
       # re-runs the complete unsharded aggregate on the persistent 64-core VM,
       # continuously proving that environment can take over a required lane if
    -  # the hosted pools degrade (the switch is then setting the admin-only
    +  # the hosted pools degrade (the switch is then setting the writer-manageable
       # DSH_CI_FAILOVER variable — see the failover runbook, no merge required).
       # Push-triggered, so it always executes the base branch's own workflow
       # definition — no PR-editable path selects these runners. Non-blocking for
    
    From 98877ca32632a1c9681e5e2a5584ae4f8f4b2bcf Mon Sep 17 00:00:00 2001
    From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
    Date: Mon, 27 Jul 2026 15:55:21 +0800
    Subject: [PATCH 44/56] ci: drop recovered-runner fallback from retarget
    
    ---
     ...rial-cross-platform-ci-reference.i18n.yaml |  6 ++--
     ...7-21-serial-cross-platform-ci-reference.md |  6 ++--
     ...1-serial-cross-platform-ci-reference.zh.md |  6 ++--
     ...ence-based-larger-hosted-runners.i18n.yaml |  6 ++--
     ...22-evidence-based-larger-hosted-runners.md | 20 +++++------
     ...evidence-based-larger-hosted-runners.zh.md | 20 +++++------
     ...ortable-required-pull-request-ci.i18n.yaml |  6 ++--
     ...07-23-portable-required-pull-request-ci.md | 16 ++++-----
     ...23-portable-required-pull-request-ci.zh.md | 16 ++++-----
     ...able-required-status-aggregation.i18n.yaml |  6 ----
     ...27-portable-required-status-aggregation.md | 35 -------------------
     ...portable-required-status-aggregation.zh.md | 35 -------------------
     .github/workflows/ci.yml                      | 26 +++++++-------
     13 files changed, 64 insertions(+), 140 deletions(-)
     delete mode 100644 .agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.i18n.yaml
     delete mode 100644 .agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.md
     delete mode 100644 .agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.zh.md
    
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    index 8cd2a0f7c8..17edb300cc 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    -2026-07-21-serial-cross-platform-ci-reference.md: 220c4b2a092ec1907482edc60a12f981fdf986a4
    -2026-07-21-serial-cross-platform-ci-reference.zh.md: 70e4c40f0f64fef4b1de05a7603ece25aaf5bea2
    +#   pnpm run verify-translation-pairing --write
    +2026-07-21-serial-cross-platform-ci-reference.md: 5433d2c51831ce61d06a16ee0b0ed982911f9218
    +2026-07-21-serial-cross-platform-ci-reference.zh.md: 041d53d13e14354c995e4b65defce94a97646b0a
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    index 220c4b2a09..5433d2c518 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
    @@ -14,7 +14,7 @@ Reviewers also need a direct answer to a simpler question: what happens when the
     
     ## Decision
     
    -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run three primary Linux jobs, one complete Windows job, and the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
    +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
     
     Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
     
    @@ -24,7 +24,7 @@ The macOS reference runs the ordinary Vitest project in forked processes. Node 2
     
     Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
     
    -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Substantive required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
    +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
     
     ## Alternatives considered
     
    @@ -32,7 +32,7 @@ The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, a
     - **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
     - **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts.
     - **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
    -- **Run the serial reference on larger runners** - rejected because substantive required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
    +- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
     
     ## Consequences
     
    diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    index 70e4c40f0f..041d53d13e 100644
    --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
    @@ -14,7 +14,7 @@ Status: implemented
     
     ## 决策
     
    -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行 3 项 Linux 主作业、1 项完整的 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
    +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
     
     每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
     
    @@ -24,7 +24,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上
     
     master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
     
    -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求中的实质性必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
    +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
     
     ## 曾考虑的替代方案
     
    @@ -32,7 +32,7 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的
     - **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
     - **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。
     - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
    -- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,承担实质性检查的必需 CI 及其独立参考流程都必须仍可运行。
    +- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。
     
     ## 后果
     
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    index eeaa689fd8..3d8e6fc395 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    -2026-07-22-evidence-based-larger-hosted-runners.md: 8a3ca991accd5bbe71b6f92cffff4c9a420b1f25
    -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 870c4301eed4793d3ac6801c310288abf1d46c3b
    +#   pnpm run verify-translation-pairing --write
    +2026-07-22-evidence-based-larger-hosted-runners.md: fe11e6929545923d27fbf41f5a39f7dd2b9c3fbf
    +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 47879284532a537cbe7e78aa2c495c4ef0be26c4
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    index 8a3ca991ac..fe11e69295 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    @@ -12,19 +12,19 @@ Larger runners make it possible to pay setup once and parallelize inside the rep
     
     ## Decision
     
    -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows as measurement infrastructure. Public IPs are disabled, and benchmark concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit.
    +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name three 32-core pools directly: Ubuntu 24.04 for exhaustive coverage, Ubuntu latest for the remaining primary Node 24 inventory, and Windows 2025 for blocking Windows contracts. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit.
     
    -Ordinary pull requests use the standard-hosted primary path owned by the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md). `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. The [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keeps an independent complete standard-runner oracle on `master`.
    +The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work.
     
     The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture.
     
    -Linux primary work uses three independent standard-hosted jobs with single-worker inner bounds. Coverage runs alone, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
    +Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
     
     The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking builds its complete project-reference graph once. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count.
     
     The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails.
     
    -Windows shares one standard-hosted setup across the blocking build and production site plus observational built-artifact contracts, with single-worker bounds. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the critical path without adding a blocking platform claim.
    +Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim.
     
     An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction:
     
    @@ -50,11 +50,11 @@ Inner and outer worker limits are separate controls. An [exact-head 32-worker ES
     
     The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection.
     
    -Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the standard-hosted required path, while larger-runner sizes run only by manual dispatch.
    +Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch.
     
     ## Alternatives considered
     
    -**Restore the former core, CPU, and production-site lanes.** Those jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute.
    +**Keep the three coarse primary Linux lanes.** The core, CPU, and production-site jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute.
     
     **Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises.
     
    @@ -68,7 +68,7 @@ Complete serial Linux, macOS, and Windows references run only when `master` move
     
     **Keep static gates and post-build consumers on one runner.** Reusing one workspace avoids a setup wave and artifact transfer, but build-duration variance delays every consumer and leaves their lint and snapshot tails after the static result. A run-scoped built tree preserves one exact build while independent jobs keep both complete paths within the observed target.
     
    -**Use larger-runner pools as the required default.** This offers lower measured latency when allocation works, but a missing entitlement or delayed enterprise transfer leaves required jobs queued without repository diagnostics. The portable path accepts longer runtime, and manual suites preserve the performance experiment.
    +**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path.
     
     **Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process.
     
    @@ -76,10 +76,10 @@ Complete serial Linux, macOS, and Windows references run only when `master` move
     
     ## Consequences
     
    -The required topology pays one setup wave per standard-hosted lane and retains no shard selectors. The substantive CI inventory consumes enterprise larger-runner minutes only when a benchmark is dispatched; the lightweight aggregate's separate runner choice is outside this decision.
    +The required topology pays one setup wave per 32-core lane and retains no shard selectors. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful.
     
    -Splitting Linux repeats setup twice and transfers one built tree, but isolates coverage, static gates, and post-build consumers from each other's critical paths without repeating the build. Consolidating Windows avoids repeating its slower setup. The trade-off is longer elapsed time than the measured larger-runner topology.
    +GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice and transfers one built tree, but isolates coverage, static gates, and post-build consumers from each other's critical paths without repeating the build; consolidating Windows avoids repeating its slower setup.
     
     Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement.
     
    -Missing or renamed enterprise labels leave manual benchmarks unavailable without queueing a substantive primary job. The retained pools can compare sizes after allocation recovers without making runner assignment an operational dependency of repository gate execution.
    +Missing or renamed enterprise labels leave required primary jobs queued. Standard-hosted compatibility jobs and `master` references still report useful evidence, but they do not substitute for the required aggregate; runner assignment is therefore an operational dependency that repository CI cannot repair.
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    index 870c4301ee..4787928453 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    @@ -12,19 +12,19 @@ Status: implemented
     
     ## 决策
     
    -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池,作为测量基础设施。公网 IP 已禁用;基准测试并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。
    +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 3 个 32 核运行器池:Ubuntu 24.04 用于完整覆盖率,Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。
     
    -普通拉取请求使用由[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)规定的标准托管主路径。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)在 `master` 上保留一套独立、完整的标准运行器判定基准。
    +必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。
     
     原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。
     
    -Linux 主流程使用 3 项相互独立的标准托管作业,内部均采用单工作线程上限。覆盖率单独运行;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
    +Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
     
     门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查只构建一次完整的 project-reference 图。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。
     
     产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布契约只要遗漏一个运行时分片,检查仍会失败。
     
    -Windows 以一次标准托管环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约,并采用单工作线程上限。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长关键路径,却不会新增任何阻塞性平台契约。
    +Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。
     
     一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程:
     
    @@ -50,11 +50,11 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
     
     进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于能否得到确定的覆盖率结果,而非标称核心数。
     
    -只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用标准托管的必需路径,大型运行器规格仅通过手动触发运行。
    +只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。
     
     ## 曾考虑的替代方案
     
    -**恢复原有的核心、CPU 和生产网站通道。** 这些作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。
    +**保留 3 个粗粒度 Linux 主流程通道。** 核心、CPU 和生产网站作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。
     
     **将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。
     
    @@ -68,7 +68,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
     
     **将静态门禁和构建后消费方保留在同一台运行器上。** 复用同一个工作区可以省去一轮设置和一次产物传输,但构建耗时的波动会延迟每个消费方,并使消费方的 lint 和快照尾段延续到静态结果之后。仅供本次运行使用的已构建目录树可以保留同一份构建结果,而相互独立的作业能让两条完整路径都保持在实测目标内。
     
    -**将大型运行器池作为必需作业的默认运行环境。** 当运行器能够分配时,此方案可提供实测更低的延迟;但若未获得相应使用权限或企业转移延迟,必需作业会持续排队,且不会发出仓库诊断。可移植路径接受更长的运行时间,手动套件则保留性能实验。
    +**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。
     
     **将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。
     
    @@ -76,10 +76,10 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
     
     ## 后果
     
    -必需拓扑中的每个标准托管通道只承担 1 轮设置开销,且不保留分片选择器。只有在触发基准测试时,实质性 CI 清单才会消耗企业级大型运行器分钟数;轻量级聚合流程单独选择运行器,不属于本决策范围。
    +必需拓扑中的每个 32 核通道只承担 1 轮设置开销,且不保留分片选择器。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows 运行器分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。
     
    -拆分 Linux 会重复两轮设置并传输一份已构建目录树,但能使覆盖率、静态门禁与构建后消费方不再相互进入关键路径,且无需重复构建。合并 Windows 可避免重复其耗时更长的设置。代价是总耗时长于经测量的大型运行器拓扑。
    +GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置并传输一份已构建目录树,但能使覆盖率、静态门禁与构建后消费方不再相互进入关键路径,且无需重复构建;合并 Windows 则避免重复其耗时更长的设置。
     
     性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。
     
    -企业级运行器标签缺失或改名时,手动基准测试会不可用,但不会让实质性主作业排队。运行器分配能力恢复后,保留的运行器池仍可比较不同规格,同时不会让运行器分配成为仓库门禁执行的运维依赖。
    +企业级运行器标签缺失或改名时,必需主作业会持续排队。标准托管兼容性作业与 `master` 参考流程仍会报告有用证据,但不能替代必需聚合流程;因此,运行器分配是一项仓库 CI 无法修复的运维依赖。
    diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
    index 34ea2f4a90..05147cd54a 100644
    --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
    @@ -1,6 +1,6 @@
     # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    -2026-07-23-portable-required-pull-request-ci.md: 4fd915a00300922d7318c78d16e1e8078b5170ed
    -2026-07-23-portable-required-pull-request-ci.zh.md: 9d489970a46279de8033cb82af64489d86b20d2c
    +#   pnpm run verify-translation-pairing --write
    +2026-07-23-portable-required-pull-request-ci.md: d1002c7d9db7cd8bbed3bdfda8a773a4b124bf16
    +2026-07-23-portable-required-pull-request-ci.zh.md: fedfc6b9c982ace5ece430c52db23c22ec5119d4
    diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    index 4fd915a003..d1002c7d9d 100644
    --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
    @@ -12,24 +12,24 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei
     
     ## Decision
     
    -[CI](../../../../.github/workflows/ci.yml) runs the three required primary Node 24 jobs on standard `ubuntu-latest` and the complete required Windows job on standard `windows-2025`. Static gates publish their exact built tree for the snapshot and artifact job, while coverage remains independent. Top-level gates, coverage, ESLint, publint, and snapshot replay use single-worker bounds on these smaller hosts. Node 22.19, Node 26, and Python SDK compatibility also use standard capacity. The lightweight `all checks passed` aggregate remains a separate scheduling decision because it performs no checkout or repository gate.
    +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs on standard `windows-2025` with single-worker bounds, keeping the complete Windows contract independent of enterprise Windows allocation. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
     
    -The three Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; no gate is removed or made observational to recover availability. Branch protection continues to require `e2e` and `all checks passed`.
    +The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
     
    -The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the retained performance measurements and manual suites. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check.
    +The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix.
     
     ## Alternatives considered
     
    -**Wait for enterprise allocation to recover.** A queue with no assigned runner emits no repository diagnostic and can block every pull request indefinitely, so external recovery is not a correctness path.
    +**Keep the Linux primary jobs and aggregate on standard capacity.** This removes the remaining enterprise allocation dependency, but complete standard-runner jobs give materially slower feedback and still experience shared-capacity queues. The current split retains portable compatibility and serial evidence while spending enterprise capacity on the Linux primary critical path.
     
    -**Use only the smallest enterprise pools.** Every named pool crosses the same enterprise allocation boundary; reducing core count does not remove the dependency that caused the queue.
    +**Select enterprise size from advertised core count.** Benchmarks show non-monotonic scaling and setup variance, so exact complete-job measurements choose the required pools instead.
     
     **Skip or demote checks while capacity is unavailable.** This would make the status green by dropping evidence rather than by running the repository's required contracts.
     
    -**Keep larger-runner worker limits on standard runners.** Concurrent repository gates and their inner worker pools can oversubscribe the smaller memory and CPU allocation, turning an availability repair into contention failures.
    +**Use one worker policy on every host.** Outer gate concurrency and inner tool workers contend differently on Linux, Windows, and standard runners; measured host-specific bounds avoid turning additional cores into slower execution.
     
     ## Consequences
     
    -Ordinary pull requests can acquire every substantive runner without enterprise-specific configuration. A live exact-head run proves the same commands that branch protection consumes, at the cost of longer elapsed time on smaller hosts.
    +Ordinary pull requests spend enterprise capacity on the Linux critical path while standard Windows trades longer runtime for independent allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
     
    -Manual larger-runner benchmarks can remain queued without blocking pull requests. Restoring larger runners to the required path needs a separate evidence-based decision after exact-head jobs receive nonzero runner IDs and complete reliably; changing a pool definition's status alone is insufficient.
    +Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work.
    diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
    index 9d489970a4..fedfc6b9c9 100644
    --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
    @@ -12,24 +12,24 @@ Status: implemented
     
     ## 决策
     
    -[CI](../../../../.github/workflows/ci.yml) 在标准 `ubuntu-latest` 上运行 3 项必需的主 Node 24 作业,并在标准 `windows-2025` 上运行完整的必需 Windows 作业。静态门禁发布其完全一致的已构建目录树,供快照与产物作业使用;覆盖率作业则保持独立。这些较小主机上的顶层门禁、覆盖率、ESLint、publint 和快照回放均采用单工作线程上限。Node 22.19、Node 26 和 Python SDK 兼容性也使用标准容量。轻量级 `all checks passed` 聚合流程仍由单独的调度决策管理,因为它不执行代码检出或仓库门禁。
    +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `windows-2025` 上运行,并采用单工作线程上限,使完整的 Windows 契约不依赖企业级 Windows 运行器分配。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
     
    -3 项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;为恢复可用性,没有移除任何门禁,也没有将任何门禁改为仅供观测。分支保护继续要求 `e2e` 和 `all checks passed`。
    +两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。
     
    -保留的性能测量结果与手动套件由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查。
    +当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。
     
     ## 曾考虑的替代方案
     
    -**等待企业级运行器分配恢复。** 未分配运行器的队列不会发出任何仓库诊断,并且可能无限期阻塞所有拉取请求,因此外部恢复不能作为正确性路径。
    +**将 Linux 主作业和聚合流程保留在标准容量上。** 此方案消除了剩余的企业级运行器分配依赖,但标准运行器上的完整作业反馈明显更慢,仍会遇到共享容量排队。当前拆分既保留可移植兼容性和串行证据,又将企业级运行器容量用于 Linux 主关键路径。
     
    -**仅使用最小的企业级运行器池。** 无论指定哪个运行器池,都要经过同一个企业级分配边界;减少核心数并不能消除导致排队的依赖。
    +**根据标称核心数选择企业规格。** 基准测试表明扩展效果不呈单调变化,设置耗时也存在波动,因此必需运行器池改由完整作业的精确测量结果选定。
     
     **在容量不可用时跳过检查或降低其级别。** 这种方式通过丢弃证据而非执行仓库的必需契约来使状态变绿。
     
    -**在标准运行器上沿用大型运行器的工作线程上限。** 并发运行的仓库门禁及其内部工作线程池,可能让并发需求超过较小的内存和 CPU 配额,使可用性修复反而引发资源争用故障。
    +**在每台主机上使用同一工作线程策略。** 外层门禁并发与内层工具工作线程在 Linux、Windows 和标准运行器上的争用方式不同;按主机实测的上限可以避免新增核心反而拖慢执行。
     
     ## 后果
     
    -普通拉取请求无需企业专用配置,即可为每项实质性作业获得运行器。一次实际的分支头精确运行能够证明分支保护使用的同一组命令,代价是在较小主机上耗时更长。
    +普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而标准托管 Windows 作业则以更长的运行时间换取不依赖企业池的运行器分配。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。
     
    -手动大型运行器基准测试即使持续排队,也不会阻塞拉取请求。只有在分支头精确作业获得非零运行器 ID 并稳定完成后,才能另行作出基于证据的决策,将大型运行器恢复到必需路径;仅改变运行器池定义的状态仍然不够。
    +企业级运行器分配能力下降时,标准兼容性作业和必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。
    diff --git a/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.i18n.yaml
    deleted file mode 100644
    index a029a82389..0000000000
    --- a/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.i18n.yaml
    +++ /dev/null
    @@ -1,6 +0,0 @@
    -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
    -# side as of the last confirmed-consistent state. Both languages carry equal authority;
    -# after editing either side, bring the other along and re-record with:
    -#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.md
    -2026-07-27-portable-required-status-aggregation.md: 081d841cfb939c97f189c46fc0985f9ff2d1987d
    -2026-07-27-portable-required-status-aggregation.zh.md: 896e9500d2ad6e2e0ef6c6cc7a37373c6d28b303
    diff --git a/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.md b/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.md
    deleted file mode 100644
    index 081d841cfb..0000000000
    --- a/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.md
    +++ /dev/null
    @@ -1,35 +0,0 @@
    -# Agent Note: Portable required-status aggregation
    -
    -Status: implemented
    -
    -English | [中文](2026-07-27-portable-required-status-aggregation.zh.md)
    -
    -## Problem
    -
    -Branch protection consumes one stable `all checks passed` job instead of tracking the changing names of matrix legs and execution lanes. This job performs no repository work: after its blocking dependencies finish, it only reduces their results into the required verdict.
    -
    -Assigning that bookkeeping job to a custom runner pool adds an external allocation dependency without using the pool's additional CPU or memory. A provisioning failure can therefore leave the final required status queued even after every substantive check has produced its evidence.
    -
    -## Decision
    -
    -The `all-checks-passed` job in [CI](../../../../.github/workflows/ci.yml) runs on standard GitHub-hosted `ubuntu-latest`. It keeps every blocking job in `needs`, retains its load-bearing `if: always()` condition, fails when any dependency is failed, cancelled, or skipped, and succeeds only when every dependency succeeds. It performs no checkout, toolchain setup, dependency installation, or repository gate.
    -
    -The aggregate depends only on production standard-hosted capacity; it does not use organization-defined, enterprise-defined, or self-hosted labels. Substantive jobs choose their own runner topology independently. Moving this verdict does not change their commands, weaken their evidence, or make an unresolved dependency pass: the aggregate waits for unfinished dependencies and fails on non-success terminal results.
    -
    -This decision supersedes only the aggregate-placement clause in the [portable pull-request CI recovery boundary](2026-07-23-portable-required-pull-request-ci.md), which continues to own the substantive jobs' recovery topology. The final bookkeeping status remains separately owned so runner-topology changes and branch-protection aggregation can evolve independently.
    -
    -## Alternatives considered
    -
    -**Run the aggregate beside substantive jobs on a custom enterprise pool.** This avoids one short standard-hosted allocation, but gives the bookkeeping job a provisioning failure mode without using the larger machine's capacity.
    -
    -**Use a standby self-hosted runner.** This replaces one external readiness dependency with another and makes a required verdict depend on a separately operated machine. Managed standard-hosted capacity is the production path for this bookkeeping work.
    -
    -**Require every substantive job directly in branch protection.** This removes the aggregate allocation, but couples repository settings to matrix and lane names that change as the CI topology evolves.
    -
    -**Treat missing or non-success dependencies as success.** This would produce a green status by discarding required evidence rather than by completing it.
    -
    -## Consequences
    -
    -Each pull request allocates one short standard-hosted job after its substantive dependencies settle. Because the job performs no checkout or setup, it adds little active runtime, but its scheduling and billing remain separate from custom pools.
    -
    -A custom-pool outage can still keep a substantive dependency queued, and the aggregate correctly waits in that case. Once the dependencies reach terminal results, the final required verdict no longer needs custom-pool or self-hosted allocation. Future changes can move substantive jobs between standard and larger runners without reintroducing that dependency into the branch-protection status.
    diff --git a/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.zh.md b/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.zh.md
    deleted file mode 100644
    index 896e9500d2..0000000000
    --- a/.agents/notes/implemented/process/2026-07-27-portable-required-status-aggregation.zh.md
    +++ /dev/null
    @@ -1,35 +0,0 @@
    -# Agent Note: 必需状态的可移植聚合
    -
    -Status: implemented
    -
    -[English](2026-07-27-portable-required-status-aggregation.md) | 中文
    -
    -## 问题
    -
    -分支保护只使用一项稳定的 `all checks passed` 作业,无需跟踪持续变化的矩阵分支名和执行通道名。该作业不执行任何仓库工作:会阻塞判定的依赖项结束后,它只将这些依赖项的结果归并为必需判定。
    -
    -将这项结果汇总作业分配给自定义运行器池,会在不使用该池额外 CPU 或内存的情况下增加一项外部运行器分配依赖。因此,即使所有实质性检查都已产出证据,预配失败仍可能让最终的必需状态持续排队。
    -
    -## 决策
    -
    -[CI](../../../../.github/workflows/ci.yml) 中的 `all-checks-passed` 作业在 GitHub 标准托管的 `ubuntu-latest` 上运行。它在 `needs` 中保留所有会阻塞判定的作业,保留承重的 `if: always()` 条件;任何依赖项失败、被取消或被跳过时,该作业都会失败,只有所有依赖项都成功时才会成功。它不执行代码检出、工具链设置、依赖安装或仓库门禁。
    -
    -聚合作业只依赖生产环境的标准托管容量;它不使用组织定义的、企业定义的或自托管的运行器标签。实质性作业各自独立选择运行器拓扑。调整这项判定作业的运行位置,不会改变实质性作业的命令、削弱其证据或使未完成的依赖项通过:聚合作业会等待尚未结束的依赖项,并在依赖项产生非成功的终态结果时失败。
    -
    -本决策仅取代[拉取请求 CI 的可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)中关于聚合作业运行位置的条款;该记录继续规定实质性作业的恢复拓扑。最终的结果汇总状态仍由本决策单独规定,使运行器拓扑变更与分支保护聚合可以独立演进。
    -
    -## 曾考虑的替代方案
    -
    -**让聚合作业与实质性作业一同在自定义企业级运行器池上运行。** 此方案可以避免一次短暂的标准托管运行器分配,但会在未使用大型机器容量的情况下,为结果汇总作业引入预配失败的故障模式。
    -
    -**使用备用自托管运行器。** 此方案只是用另一项外就绪状态依赖替换原有依赖,并使必需判定依赖一台单独运维的机器。由平台管理的标准托管容量是这项结果汇总工作的生产路径。
    -
    -**在分支保护中直接要求每项实质性作业。** 此方案不再需要为聚合作业分配运行器,但会将仓库设置与随 CI 拓扑演进而变化的矩阵分支名和通道名耦合。
    -
    -**将缺失或非成功的依赖项视为成功。** 这种做法不是通过完成相应检查来产出必需证据,而是丢弃这些证据以产出绿色状态。
    -
    -## 后果
    -
    -每个拉取请求都会在实质性依赖项的结果确定后分配一项短时运行的标准托管作业。由于该作业不执行代码检出或设置,它只增加少量活跃运行时间,但其调度和计费仍独立于自定义运行器池。
    -
    -自定义运行器池不可用仍可能让实质性依赖项持续排队,聚合作业在这种情况下会按设计等待。依赖项产生终态结果后,最终的必需判定不再需要自定义运行器池或自托管运行器分配。未来可以在标准运行器与大型运行器之间迁移实质性作业,而不会将这项依赖重新引入分支保护状态。
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index b3292aeeeb..f02d30a563 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -27,15 +27,15 @@ env:
     
     jobs:
     
    -  # Three standard Linux jobs isolate coverage, static analysis, and the
    +  # Three enterprise jobs isolate coverage, static analysis, and the
       # build-backed consumer tail. The static job publishes its exact build so
       # consumers do not repeat the longest part of their critical path.
       node-24:
         if: github.event_name == 'pull_request'
    -    runs-on: ubuntu-latest
    +    runs-on: dsh-enterprise-ubuntu-latest-32core-test
         name: node 24 / static
         env:
    -      DSH_GATE_CONCURRENCY: '1'
    +      DSH_GATE_CONCURRENCY: '8'
         steps:
           # Fetch complete history so the archive gate can read the trusted PR base from a reused shallow checkout.
           - uses: actions/checkout@v6
    @@ -81,11 +81,11 @@ jobs:
     
       node-24-coverage:
         if: github.event_name == 'pull_request'
    -    runs-on: ubuntu-latest
    +    runs-on: dsh-enterprise-ubuntu-24-04-32core-test
         name: node 24 / coverage
         env:
    -      DSH_COVERAGE_MAX_WORKERS: '1'
    -      DSH_GATE_CONCURRENCY: '1'
    +      DSH_COVERAGE_MAX_WORKERS: '24'
    +      DSH_GATE_CONCURRENCY: '8'
         steps:
           - uses: actions/checkout@v6
             with:
    @@ -122,15 +122,15 @@ jobs:
       node-24-consumers:
         needs: node-24
         if: github.event_name == 'pull_request'
    -    runs-on: ubuntu-latest
    +    runs-on: dsh-enterprise-ubuntu-latest-32core-test
         name: node 24 / snapshots and artifacts
         env:
           DSH_ESLINT_CACHE: '1'
    -      DSH_ESLINT_CONCURRENCY: '1'
    -      DSH_GATE_CONCURRENCY: '1'
    +      DSH_ESLINT_CONCURRENCY: '8'
    +      DSH_GATE_CONCURRENCY: '8'
           DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
    -      DSH_PUBLINT_CONCURRENCY: '1'
    -      DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
    +      DSH_PUBLINT_CONCURRENCY: '8'
    +      DSH_SNAPSHOT_MAX_CONCURRENCY: '32'
         steps:
           - uses: actions/checkout@v6
             with:
    @@ -693,8 +693,8 @@ jobs:
       # 'cancelled' and 'skipped'.
       all-checks-passed:
         name: all checks passed
    -    # This bookkeeping-only verdict must not depend on custom-pool provisioning.
    -    runs-on: ubuntu-latest
    +    # The required verdict must not add a separate standard-hosted billing dependency.
    +    runs-on: dsh-enterprise-ubuntu-latest-32core-test
         needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows]
         if: always() && github.event_name == 'pull_request'
         steps:
    
    From 62e2551edd69f3277dd07ce92fb0f59840a3e257 Mon Sep 17 00:00:00 2001
    From: 07akioni <07akioni2@gmail.com>
    Date: Mon, 27 Jul 2026 16:01:01 +0800
    Subject: [PATCH 45/56] =?UTF-8?q?fix:=20darkmode=20=E6=BB=9A=E5=8A=A8?=
     =?UTF-8?q?=E6=9D=A1=E9=A2=9C=E8=89=B2?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    ---
     packages/client/ui-layout/README.md           |  2 +-
     packages/client/ui-layout/README.zh.md        |  2 +-
     .../ui-layout/src/client/theme-presenter.ts   | 27 +++++++++++--------
     packages/client/ui-layout/tests/apply.spec.ts |  6 ++++-
     .../ui-layout/tests/theme-presenter.spec.ts   | 17 +++++++-----
     packages/client/ui-theme/README.md            |  2 +-
     packages/client/ui-theme/README.zh.md         |  2 +-
     7 files changed, 36 insertions(+), 22 deletions(-)
    
    diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md
    index 6aeda0e04b..26e909b964 100644
    --- a/packages/client/ui-layout/README.md
    +++ b/packages/client/ui-layout/README.md
    @@ -2,7 +2,7 @@
     
     English | [中文](README.zh.md)
     
    -Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto `document.body` (`data-ds-dark-theme` from the active color scheme plus the theme's alias tokens as inline variables).
    +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
     
     AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.
     
    diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md
    index 9fbc2ed839..2e5799fd32 100644
    --- a/packages/client/ui-layout/README.zh.md
    +++ b/packages/client/ui-layout/README.zh.md
    @@ -2,7 +2,7 @@
     
     [English](README.md) | 中文
     
    -外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 `document.body`(依据当前配色方案设置 `data-ds-dark-theme`,并将主题的别名 token 设为内联变量)。
    +外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
     
     AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。
     
    diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts
    index 958f2fd93e..07dc663c54 100644
    --- a/packages/client/ui-layout/src/client/theme-presenter.ts
    +++ b/packages/client/ui-layout/src/client/theme-presenter.ts
    @@ -1,29 +1,33 @@
     /**
    - * Global theme DOM applier: projects the resolved ThemeSnapshot onto
    - * document.body — the `data-ds-dark-theme` palette switch plus the active
    - * theme's alias-token overrides as inline CSS variables. Pure DOM writes, no
    - * React involvement; the presenter only ever retracts what it wrote itself,
    - * so foreign body attributes and inline styles survive apply/dispose.
    + * Global theme DOM applier: projects the resolved ThemeSnapshot onto the
    + * document — `html { color-scheme }` for native UA chrome (scrollbars, form
    + * controls), `body[data-ds-dark-theme]` for the token palette, and the active
    + * theme's alias-token overrides as inline CSS variables on body. Pure DOM
    + * writes, no React involvement; the presenter only ever retracts what it wrote
    + * itself, so foreign attributes and inline styles survive apply/dispose.
      */
     import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
     
     /** Body attribute selecting the dark base palette in the token stylesheets. */
     export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
     
    -/** Applies theme snapshots to document.body; one instance per plugin fiber. */
    +/** Applies theme snapshots to the document; one instance per plugin fiber. */
     export class ThemePresenter {
       /** Token names this presenter wrote in the last apply (its retraction set). */
       private appliedTokens: string[] = []
     
       /**
    -   * Project a snapshot onto the body: switch the palette attribute from
    -   * `active.colorScheme` (never the id — `system` is resolved upstream) and
    -   * replace the previously applied token variables with `active.tokens`.
    +   * Project a snapshot onto the document: set root `color-scheme` and the body
    +   * palette attribute from `active.colorScheme` (never the id — `system` is
    +   * resolved upstream), then replace the previously applied token variables
    +   * with `active.tokens`.
        * @param snapshot - resolved theme snapshot from ctx.theme.
        */
       apply(snapshot: ThemeSnapshot): void {
    +    const scheme = snapshot.active.colorScheme
    +    document.documentElement.style.colorScheme = scheme
         const body = document.body
    -    if (snapshot.active.colorScheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '')
    +    if (scheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '')
         else body.removeAttribute(DARK_ATTRIBUTE)
         for (const name of this.appliedTokens) body.style.removeProperty(name)
         this.appliedTokens = []
    @@ -33,8 +37,9 @@ export class ThemePresenter {
         }
       }
     
    -  /** Retract everything this presenter wrote: the palette attribute and all applied token variables. */
    +  /** Retract everything this presenter wrote: root color-scheme, the palette attribute, and all applied token variables. */
       dispose(): void {
    +    document.documentElement.style.removeProperty('color-scheme')
         const body = document.body
         body.removeAttribute(DARK_ATTRIBUTE)
         for (const name of this.appliedTokens) body.style.removeProperty(name)
    diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts
    index 1382f5160d..903591163c 100644
    --- a/packages/client/ui-layout/tests/apply.spec.ts
    +++ b/packages/client/ui-layout/tests/apply.spec.ts
    @@ -63,15 +63,19 @@ describe('ui-layout client apply', () => {
         const fiber = ctx.plugin({ inject: [...inject], apply })
         await fiber.await()
         // Initial getter application: jsdom has no matchMedia, system resolves light.
    +    expect(document.documentElement.style.colorScheme).toBe('light')
         expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
         const theme = ctx.get('theme') as ThemeService
         theme.setTheme('dark')
    +    expect(document.documentElement.style.colorScheme).toBe('dark')
         expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
         await fiber.dispose()
    +    expect(document.documentElement.style.colorScheme).toBe('')
         expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
    -    // Listener is off: further theme changes no longer reach the body.
    +    // Listener is off: further theme changes no longer reach the document.
         theme.setTheme('light')
         theme.setTheme('dark')
    +    expect(document.documentElement.style.colorScheme).toBe('')
         expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
       })
     
    diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.spec.ts
    index ced83a379e..a14d781e5f 100644
    --- a/packages/client/ui-layout/tests/theme-presenter.spec.ts
    +++ b/packages/client/ui-layout/tests/theme-presenter.spec.ts
    @@ -1,7 +1,7 @@
     // @vitest-environment jsdom
    -// ThemePresenter behavior account: the palette attribute follows
    -// active.colorScheme only, token variables replace the previous apply's set,
    -// and dispose retracts everything the presenter wrote.
    +// ThemePresenter behavior account: root color-scheme and the palette attribute
    +// follow active.colorScheme only, token variables replace the previous apply's
    +// set, and dispose retracts everything the presenter wrote.
     
     import { beforeEach, describe, expect, it } from 'vitest'
     import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
    @@ -14,22 +14,26 @@ function snapshot(colorScheme: 'light' | 'dark', tokens: Record
     }
     
     beforeEach(() => {
    +  document.documentElement.style.removeProperty('color-scheme')
       document.body.removeAttribute(DARK_ATTRIBUTE)
       document.body.removeAttribute('style')
     })
     
     describe('ThemePresenter', () => {
    -  it('light scheme leaves the dark attribute absent', () => {
    +  it('light scheme sets root color-scheme and leaves the dark attribute absent', () => {
         const presenter = new ThemePresenter()
         presenter.apply(snapshot('light'))
    +    expect(document.documentElement.style.colorScheme).toBe('light')
         expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
       })
     
    -  it('dark scheme sets the attribute; switching back to light removes it', () => {
    +  it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => {
         const presenter = new ThemePresenter()
         presenter.apply(snapshot('dark'))
    +    expect(document.documentElement.style.colorScheme).toBe('dark')
         expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
         presenter.apply(snapshot('light'))
    +    expect(document.documentElement.style.colorScheme).toBe('light')
         expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
       })
     
    @@ -44,11 +48,12 @@ describe('ThemePresenter', () => {
         expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('')
       })
     
    -  it('dispose removes the attribute and every applied variable, sparing foreign inline styles', () => {
    +  it('dispose removes color-scheme, the attribute, and every applied variable, sparing foreign inline styles', () => {
         document.body.style.setProperty('--foreign', 'kept')
         const presenter = new ThemePresenter()
         presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' }))
         presenter.dispose()
    +    expect(document.documentElement.style.colorScheme).toBe('')
         expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
         expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('')
         expect(document.body.style.getPropertyValue('--foreign')).toBe('kept')
    diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md
    index 5c9794d4f5..1227df357c 100644
    --- a/packages/client/ui-theme/README.md
    +++ b/packages/client/ui-theme/README.md
    @@ -2,7 +2,7 @@
     
     English | [中文](README.zh.md)
     
    -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8.
    +Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8.
     
     ## Model Experience
     
    diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md
    index 2e0f76133e..cd87ede726 100644
    --- a/packages/client/ui-theme/README.zh.md
    +++ b/packages/client/ui-theme/README.zh.md
    @@ -2,7 +2,7 @@
     
     [English](README.md) | 中文
     
    -主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为内联变量)。契约:api-contracts v3 §8。
    +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。契约:api-contracts v3 §8。
     
     ## 模型体验
     
    
    From 24d7211f09d28055c0795eac4c91f924d3593e3c Mon Sep 17 00:00:00 2001
    From: Chinesezjc 
    Date: Mon, 27 Jul 2026 16:02:19 +0800
    Subject: [PATCH 46/56] docs(ci): stop claiming no PR-editable path reaches the
     standby pool
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    The standby lane itself is push-only, but under failover pull_request
    jobs do reach these runners with the PR merge ref's workflow. The
    workflow comment and the larger-runner note (both languages) now state
    that plainly and name the actual boundary — repository membership
    (private, forking disabled, Dependabot excluded) — matching the
    runbook. Static gate green locally: 32 passed, 0 failed.
    ---
     ...26-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++--
     .../2026-07-22-evidence-based-larger-hosted-runners.md     | 2 +-
     .../2026-07-22-evidence-based-larger-hosted-runners.zh.md  | 2 +-
     .github/workflows/ci.yml                                   | 7 +++++--
     4 files changed, 9 insertions(+), 6 deletions(-)
    
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    index f65ebb1ba4..68b4098d4f 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    -2026-07-22-evidence-based-larger-hosted-runners.md: 180cc03ad091b2e9e96a86311515250f92065c6b
    -2026-07-22-evidence-based-larger-hosted-runners.zh.md: b81f67805fd543e81ede02ceeac2dda1831f4cef
    +2026-07-22-evidence-based-larger-hosted-runners.md: 67fc7ded5cffc6a219665f135a4c9e1cc4752691
    +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 71c5c067361b57fab5aae9e9ffa3850a30609db3
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    index 180cc03ad0..67fc7ded5c 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
    @@ -52,7 +52,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two
     
     Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch.
     
    -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled.
    +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records.
     
     ## Alternatives considered
     
    diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    index b81f67805f..71c5c06736 100644
    --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
    @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
     
     只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。
     
    -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。
    +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。
     
     ## 曾考虑的替代方案
     
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index 9fe393b93c..140ae00446 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -414,8 +414,11 @@ jobs:
       # continuously proving that environment can take over a required lane if
       # the hosted pools degrade (the switch is then setting the writer-manageable
       # DSH_CI_FAILOVER variable — see the failover runbook, no merge required).
    -  # Push-triggered, so it always executes the base branch's own workflow
    -  # definition — no PR-editable path selects these runners. Non-blocking for
    +  # Push-triggered, so this lane always executes the base branch's own
    +  # workflow definition. (Under failover, pull_request jobs do reach these
    +  # runners with the PR merge ref's workflow — the boundary there is
    +  # repository membership: private, forking disabled, Dependabot excluded.)
    +  # Non-blocking for
       # pull requests; no cache steps because the VM's persistent pnpm store and
       # tool caches make them redundant (and saving here would poison the hosted
       # cache namespace with self-hosted paths).
    
    From ce3b13bb0816d3a23b68b916087b3beef29fcc83 Mon Sep 17 00:00:00 2001
    From: Chinesezjc 
    Date: Mon, 27 Jul 2026 16:13:30 +0800
    Subject: [PATCH 47/56] =?UTF-8?q?ci:=20standby=20fetches=20full=20history;?=
     =?UTF-8?q?=20runbook=20=E2=80=94=20writer=20wording=20throughout,=20maste?=
     =?UTF-8?q?r-ref=20pinning=20incompatibility?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    - serial-linux-selfhosted checks out fetch-depth 0: depth 2 misses
      github.event.before on multi-commit or force pushes, failing the
      archive verifier on a valid tree. Full fetch is cheap against the
      VM's local mirror.
    - Runbook (both languages): every remaining admin phrasing (problem
      statement, switch heading, alternatives, consequences) now says
      writer; and the 'composes with this mechanism' claim about a
      master-ref-pinned runner group is replaced with the truth observed
      live on 2026-07-27 — master-ref pinning blocks PR failover, and the
      shipped posture is repository-scoped all-workflow group access.
    Static gate green locally: 32 passed, 0 failed.
    ---
     .../process/2026-07-26-ci-failover-runbook.i18n.yaml   |  4 ++--
     .../process/2026-07-26-ci-failover-runbook.md          | 10 +++++-----
     .../process/2026-07-26-ci-failover-runbook.zh.md       | 10 +++++-----
     .github/workflows/ci.yml                               |  9 +++++----
     4 files changed, 17 insertions(+), 16 deletions(-)
    
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    index de9ba10469..658a85ce34 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    -2026-07-26-ci-failover-runbook.md: 05014454fa3e38045b89a857c346db0f897ab5a6
    -2026-07-26-ci-failover-runbook.zh.md: e106a0de40799ca1c218217ea66c24068697dc53
    +2026-07-26-ci-failover-runbook.md: ca4349661d03ff4e28d7c3c2b6e910106ff4aa30
    +2026-07-26-ci-failover-runbook.zh.md: 1d59bd537879f531c9075e833c9e1dbfbd4bb0a2
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    index 05014454fa..ca4349661d 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    @@ -6,7 +6,7 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md)
     
     ## Problem
     
    -The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) and the required verdict job that aggregates them (`all checks passed`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch a repository admin can throw without merging anything.
    +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) and the required verdict job that aggregates them (`all checks passed`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch any responder with repository write access can throw without merging anything.
     
     ## Decision
     
    @@ -16,7 +16,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver
     
     `vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity.
     
    -### Switch (repo admin, ~1 minute, no merge)
    +### Switch (any repository writer, ~1 minute, no merge)
     
     1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`.
     2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
    @@ -37,14 +37,14 @@ Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhos
     
     ### Trust boundary
     
    -The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism.
    +The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the four failover jobs are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access.
     
     ## Alternatives considered
     
    -**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is admin-controlled state that takes effect on re-run without a merge.
    +**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is writer-manageable state that takes effect on re-run without a merge.
     
     **Keep the self-hosted pool always in the required path.** Rejected because it trades hosted-pool availability for the in-house VM's, moving a single point of failure rather than adding a fallback. The variable keeps the hosted pools primary and the self-hosted pool a proven, one-action standby.
     
     ## Consequences
     
    -Recovering from a hosted-pool outage is a single admin variable plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg.
    +Recovering from a hosted-pool outage is a single variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg.
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    index e106a0de40..1d59bd5378 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    @@ -6,7 +6,7 @@ Status: implemented
     
     ## 问题
     
    -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)以及聚合它们的必需判定作业(`all checks passed`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个仓库管理员无需合并任何代码即可触发的开关。
    +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)以及聚合它们的必需判定作业(`all checks passed`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。
     
     ## 决策
     
    @@ -16,7 +16,7 @@ Status: implemented
     
     `vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。
     
    -### 切换步骤(仓库管理员,约 1 分钟,无需合并)
    +### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并)
     
     1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。
     2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
    @@ -37,14 +37,14 @@ Status: implemented
     
     ### 信任边界
     
    -该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。(运行器侧的组织级 runner group 约束另行跟踪,与本机制互补。)
    +该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——四个故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。
     
     ## 曾考虑的替代方案
     
    -**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是管理员控制的状态,重跑即生效,无需合并。
    +**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是写者可管理的状态,重跑即生效,无需合并。
     
     **让自托管池长期处于必需路径中。** 否决,因为这是拿托管池的可用性去换自有虚拟机的可用性,只是搬移了单点故障而非增加回退。该变量让托管池保持主路径,自托管池作为一个经过验证、一步即可启用的热备。
     
     ## 后果
     
    -从托管池故障中恢复只需一个管理员变量加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。
    +从托管池故障中恢复只需一个变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index 140ae00446..3c952a7bb3 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -427,12 +427,13 @@ jobs:
         name: serial / linux (self-hosted standby)
         runs-on: [self-hosted, linux, x64, vm-backup]
         steps:
    -      # fetch-depth 2 + DSH_ARCHIVE_BASE_REF below: same frozen-archive
    -      # comparison as serial-linux — without the prior commit the archive
    -      # verifier defaults to HEAD and compares the new manifest with itself.
    +      # Full history + DSH_ARCHIVE_BASE_REF below: same frozen-archive
    +      # comparison as serial-linux. Depth 2 would miss github.event.before
    +      # on multi-commit or force pushes; full fetch is cheap here because
    +      # checkout resolves against the VM's local mirror.
           - uses: actions/checkout@v6
             with:
    -          fetch-depth: 2
    +          fetch-depth: 0
     
           - uses: actions/setup-node@v6
             with:
    
    From 3cf2853b3f04fdceb3ff99109dea8f764d28fbc3 Mon Sep 17 00:00:00 2001
    From: Chinesezjc 
    Date: Mon, 27 Jul 2026 16:22:30 +0800
    Subject: [PATCH 48/56] docs(ci): bootstrap procedure starts the listener
     service
    
    config.sh only registers; the runner stays offline until svc.sh
    install/start. Both language sides updated so emergency capacity
    actually comes online.
    ---
     .../process/2026-07-26-ci-failover-runbook.i18n.yaml          | 4 ++--
     .../implemented/process/2026-07-26-ci-failover-runbook.md     | 2 +-
     .../implemented/process/2026-07-26-ci-failover-runbook.zh.md  | 2 +-
     3 files changed, 4 insertions(+), 4 deletions(-)
    
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    index 658a85ce34..0e3b0820c0 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    -2026-07-26-ci-failover-runbook.md: ca4349661d03ff4e28d7c3c2b6e910106ff4aa30
    -2026-07-26-ci-failover-runbook.zh.md: 1d59bd537879f531c9075e833c9e1dbfbd4bb0a2
    +2026-07-26-ci-failover-runbook.md: 80dd7c4291e3de11c2f13b3247af56762396c720
    +2026-07-26-ci-failover-runbook.zh.md: 7933a857f1559c540fccc2cd89352c4fe351dd7e
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    index ca4349661d..80dd7c4291 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
    @@ -28,7 +28,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver
     
     ## Capacity during failover
     
    -Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh`; copying `.runner`/`.credentials` verbatim makes `config.sh` refuse with "already configured". About a minute per instance.
    +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; only a started service adds capacity. About a minute per instance.
     
     
     ### Switch back
    diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    index 1d59bd5378..7933a857f1 100644
    --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
    @@ -28,7 +28,7 @@ Status: implemented
     
     ## 切换期间的容量
     
    -6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`;原样拷贝 `.runner`/`.credentials` 会使 `config.sh` 以 "already configured" 拒绝。每个约一分钟。
    +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;只有启动了服务的 runner 才会增加容量。每个约一分钟。
     
     
     ### 切回
    
    From 900e45b365ffaaa03b4bef0cf1ace5717a5e0bc5 Mon Sep 17 00:00:00 2001
    From: 07akioni <07akioni2@gmail.com>
    Date: Mon, 27 Jul 2026 16:24:30 +0800
    Subject: [PATCH 49/56] feat: optimize todo tool ui
    
    ---
     .../2026-07-23-web-todo-display.i18n.yaml     |   4 +-
     .../feature/2026-07-23-web-todo-display.md    |   2 +-
     .../feature/2026-07-23-web-todo-display.zh.md |   2 +-
     apps/web/tests/todo-display.snapshot.ts       |  12 +-
     .../client/ui-conversation/README.i18n.yaml   |   4 +-
     packages/client/ui-conversation/README.md     |   2 +-
     packages/client/ui-conversation/README.zh.md  |   2 +-
     .../src/client/skeleton/InputBar.module.css   |   5 +-
     .../src/client/skeleton/TodoPanel.module.css  | 102 ++++++++--------
     .../src/client/skeleton/TodoPanel.tsx         | 109 +++++++++++++-----
     .../ui-conversation/tests/todo-panel.spec.tsx |  26 +++--
     11 files changed, 162 insertions(+), 108 deletions(-)
    
    diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml
    index 4bccfa396e..5c8530da70 100644
    --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml
    +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md
    -2026-07-23-web-todo-display.md: 830f55c86c893c4942a1a9d3b8529395d5f5e38b
    -2026-07-23-web-todo-display.zh.md: e68928d7eddaaa92ac831722a738ee2002342b38
    +2026-07-23-web-todo-display.md: 5fe08cc40c1d23ff3a9b8c6d766fea6d3694c30d
    +2026-07-23-web-todo-display.zh.md: c121ffc27e3d0a93707c2c22b2f180023ebae5be
    diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md
    index 830f55c86c..5fe08cc40c 100644
    --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md
    +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md
    @@ -18,7 +18,7 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it
     
     ### TodoPanel: the durable list as a persistent strip
     
    -The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper.
    +The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"/ tasks ·  in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper.
     
     ### TodoRow: the per-call row through the keyed toolview slot
     
    diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md
    index e68928d7ed..c121ffc27e 100644
    --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md
    +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md
    @@ -18,7 +18,7 @@ Status: implemented
     
     ### TodoPanel:长驻列表作为一条常驻横条
     
    -面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。
    +面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks ·  in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。
     
     ### TodoRow:经 keyed toolview slot 的逐调用行
     
    diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts
    index 3116bf4242..f916b64b5b 100644
    --- a/apps/web/tests/todo-display.snapshot.ts
    +++ b/apps/web/tests/todo-display.snapshot.ts
    @@ -143,19 +143,19 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn
         })),
       }).toMatchInlineSnapshot(`
         {
    -      "panelHeader": "Plan1/3",
    +      "panelHeader": "To-dos1/3 tasks · 1 in progress",
           "panelItems": [
             {
               "status": "completed",
    -          "text": "✓梳理需求",
    +          "text": "梳理需求",
             },
             {
               "status": "in_progress",
    -          "text": "●实现 fixture 样本",
    +          "text": "实现 fixture 样本",
             },
             {
               "status": "pending",
    -          "text": "○浏览器验收",
    +          "text": "浏览器验收",
             },
           ],
           "row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本",
    @@ -164,7 +164,7 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn
       `)
     })
     
    -it('collapses the plan strip to the in-progress hint and restores it', async () => {
    +it('collapses the plan strip to the count summary and restores it', async () => {
       boot()
       await openFixtureSession()
     
    @@ -179,7 +179,7 @@ it('collapses the plan strip to the in-progress hint and restores it', async ()
         listGone: panel.querySelector('ul') === null,
       }).toMatchInlineSnapshot(`
         {
    -      "collapsedHeader": "Plan1/3实现 fixture 样本",
    +      "collapsedHeader": "To-dos1/3 tasks · 1 in progress",
           "listGone": true,
         }
       `)
    diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
    index 2ffa02d313..2f67e9eac3 100644
    --- a/packages/client/ui-conversation/README.i18n.yaml
    +++ b/packages/client/ui-conversation/README.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
    -README.md: b242812411d513931ecd2767622f9e23fb0aaa34
    -README.zh.md: 77f68e02d8d9161c413ae7d224121bc53547ba12
    +README.md: f6b7326916122545fc87d289cb422e644c7bae6c
    +README.zh.md: 55d4a743709695ba04814b4529aebac235a202d9
    diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
    index b242812411..f6b7326916 100644
    --- a/packages/client/ui-conversation/README.md
    +++ b/packages/client/ui-conversation/README.md
    @@ -12,7 +12,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
     
     Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
     
    -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the in-progress item. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
    +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"/ tasks ·  in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
     
     Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging.
     
    diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
    index 77f68e02d8..55d4a74370 100644
    --- a/packages/client/ui-conversation/README.zh.md
    +++ b/packages/client/ui-conversation/README.zh.md
    @@ -12,7 +12,7 @@
     
     工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
     
    -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带进行中条目的单行表头。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
    +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks ·  in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
     
     逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。
     
    diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
    index b17027a153..63614e3c13 100644
    --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
    +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
    @@ -21,8 +21,9 @@
       flex-direction: column;
       align-items: center;
       /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
    -     the chat scroller. Top 8 hosts the error strip's breathing room. */
    -  padding: 8px 32px 12px;
    +     the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
    +     margin + 6px here); error/status strips still carry their own margin. */
    +  padding: 6px 32px 12px;
     }
     
     .hero {
    diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css
    index 17c9c890a7..5ac38c1c67 100644
    --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css
    +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css
    @@ -1,55 +1,53 @@
    -/* Plan strip pinned above the composer: bordered card on the composer card's
    -   axis (776px column inside 32px side padding). Colors resolve through
    -   --dsw-alias-* tokens only; the active row rides the business blue, done
    -   rows fade to tertiary. */
    +/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
    +   tip surface, 14px radius, status icons + secondary item labels. Column is
    +   calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */
     
     .root {
       flex: none;
       overflow: hidden;
    -  margin: 8px auto 0;
    -  width: calc(100% - 64px);
    +  margin: 0 auto;
    +  width: calc(100% - 88px);
       max-width: 776px;
    -  border: 1px solid var(--dsw-alias-border-l2);
    -  border-radius: 12px;
    -  background: var(--dsw-alias-bg-base);
    +  border: 1px solid var(--dsw-alias-border-l1);
    +  border-radius: 14px;
    +  background: var(--dsw-specific-tip);
    +}
    +
    +.body {
    +  display: flex;
    +  flex-direction: column;
    +  gap: 10px;
    +  padding: 10px 16px;
     }
     
     .header {
       display: flex;
       align-items: center;
    -  gap: 8px;
    +  gap: 10px;
       width: 100%;
    -  padding: 8px 12px;
    +  padding: 0;
       border: none;
       background: transparent;
       text-align: left;
       cursor: pointer;
     }
     
    -.header:hover {
    -  background: var(--dsw-alias-interactive-bg-hover);
    -}
    -
     .title {
    -  font-size: 13px;
    -  line-height: 16px;
    -  font-weight: 510;
    +  flex: none;
    +  font-size: 14px;
    +  line-height: 24px;
    +  font-weight: 500;
       color: var(--dsw-alias-label-primary);
     }
     
     .progress {
    -  font-size: 12px;
    -  line-height: 16px;
    -  color: var(--dsw-alias-label-tertiary);
    -}
    -
    -.activeHint {
    -  flex: 1;
    +  flex: 1 1 auto;
       min-width: 0;
       overflow: hidden;
    -  font-size: 12px;
    -  line-height: 16px;
    -  color: var(--dsw-alias-label-secondary);
    +  font-size: 13px;
    +  line-height: 20px;
    +  font-weight: 400;
    +  color: var(--dsw-alias-label-tertiary);
       text-overflow: ellipsis;
       white-space: nowrap;
     }
    @@ -58,13 +56,15 @@
       display: grid;
       flex: none;
       place-items: center;
    -  margin-left: auto;
    -  color: var(--dsw-alias-label-secondary);
    +  color: var(--dsw-alias-label-tertiary);
     }
     
     .list {
    +  display: flex;
    +  flex-direction: column;
    +  gap: 8px;
       margin: 0;
    -  padding: 0 12px 8px;
    +  padding: 0;
       list-style: none;
       max-height: 180px;
       overflow-y: auto;
    @@ -72,40 +72,44 @@
     
     .item {
       display: flex;
    -  align-items: baseline;
    -  gap: 8px;
    -  padding: 2px 0;
    +  align-items: center;
    +  gap: 10px;
    +  min-width: 0;
       font-size: 13px;
       line-height: 20px;
       color: var(--dsw-alias-label-secondary);
     }
     
     .glyph {
    +  display: grid;
       flex: none;
    -  width: 14px;
    -  text-align: center;
    -  color: var(--dsw-alias-label-tertiary);
    +  place-items: center;
    +  width: 16px;
    +  height: 16px;
     }
     
    -.item[data-status='completed'] .content {
    -  color: var(--dsw-alias-label-tertiary);
    -  text-decoration: line-through;
    -}
    -
    -.item[data-status='completed'] .glyph {
    +.glyphCompleted {
       color: var(--dsw-alias-state-success-primary);
     }
     
    -.item[data-status='in_progress'] .content {
    -  font-weight: 510;
    -  color: var(--dsw-alias-label-primary);
    +.glyphProgress {
    +  color: var(--dsw-alias-state-business-primary);
    +  animation: todo-progress-spin 1s linear infinite;
     }
     
    -.item[data-status='in_progress'] .glyph {
    -  color: var(--dsw-alias-state-business-primary);
    +.glyphPending {
    +  color: var(--dsw-alias-label-caption);
    +}
    +
    +@keyframes todo-progress-spin {
    +  to {
    +    transform: rotate(360deg);
    +  }
     }
     
     .content {
       min-width: 0;
    -  overflow-wrap: anywhere;
    +  overflow: hidden;
    +  text-overflow: ellipsis;
    +  white-space: nowrap;
     }
    diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx
    index 283eeb3e5e..24764088ab 100644
    --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx
    +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx
    @@ -3,8 +3,9 @@
     // no data of its own, hidden while the list is empty. Mounted through the
     // 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
     // the selecting, so the panel takes the plain list and stays framework-free.
    +// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
     
    -import { useState } from 'react'
    +import { useId, useState } from 'react'
     import type { Context } from 'cordis'
     import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
     import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
    @@ -16,45 +17,89 @@ export interface TodoPanelProps {
       todos: readonly TodoItem[]
     }
     
    -/** Status glyphs mirror the TUI plan panel (✓ done / ● active / ○ pending). */
    -const STATUS_GLYPHS: Record = {
    -  completed: '✓', in_progress: '●', pending: '○',
    +/** Completed: green check ring (figma ic_ds_check_16). */
    +function CompletedGlyph() {
    +  return (
    +    
    +  )
    +}
    +
    +/** In-progress: business-blue ring fading out; CSS spins the svg. */
    +function ProgressGlyph() {
    +  const gradientId = useId()
    +  return (
    +    
    +  )
    +}
    +
    +/** Pending: dashed unstarted ring (figma 14px, dash 2.4 2.4). */
    +function PendingGlyph() {
    +  return (
    +    
    +  )
    +}
    +
    +function StatusGlyph({ status }: { status: TodoItem['status'] }) {
    +  switch (status) {
    +    case 'completed': return 
    +    case 'in_progress': return 
    +    case 'pending': return 
    +  }
    +}
    +
    +/** Header summary: "/ tasks ·  in progress". */
    +function progressLabel(todos: readonly TodoItem[]): string {
    +  const done = todos.filter(t => t.status === 'completed').length
    +  const active = todos.filter(t => t.status === 'in_progress').length
    +  return `${done}/${todos.length} tasks · ${active} in progress`
     }
     
     export function TodoPanel({ todos }: TodoPanelProps) {
       const [collapsed, setCollapsed] = useState(false)
       if (todos.length === 0) return null
     
    -  const done = todos.filter(t => t.status === 'completed').length
    -  const active = todos.find(t => t.status === 'in_progress')
    -
       return (
    -    
    - + {!collapsed && ( +
      + {todos.map(item => ( +
    • + + {item.content} +
    • + ))} +
    )} - - {collapsed ? : } - - - {!collapsed && ( -
      - {todos.map(item => ( -
    • - {STATUS_GLYPHS[item.status]} - {item.content} -
    • - ))} -
    - )} +
    ) } diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 5bc3aa5c3d..8888fd5659 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom /** * Todo display acceptance: the TodoPanel plan strip (empty-hidden, status - * rows, collapse with active hint), its TodoDock adapter (selects the plan off - * the session snapshot and follows changes), and the todo_write toolview row - * (progress summary from args, generic fallback on malformed JSON, error badge, + * rows, collapse), its TodoDock adapter (selects the plan off the session + * snapshot and follows changes), and the todo_write toolview row (progress + * summary from args, generic fallback on malformed JSON, error badge, * keyboard activation). */ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -31,32 +31,36 @@ describe('TodoPanel', () => { expect(container.innerHTML).toBe('') }) - it('shows progress, one row per item with its status, and strikes done items', () => { + it('shows progress, one row per item with its status glyph', () => { render() expect(screen.getByTestId('todo-panel')).toBeTruthy() - expect(screen.getByText('1/3')).toBeTruthy() + expect(screen.getByText('To-dos')).toBeTruthy() + expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() const items = screen.getAllByRole('listitem') expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending']) expect(screen.getByText('搭骨架')).toBeTruthy() expect(screen.getByText('写组件')).toBeTruthy() + // Each status row carries an SVG glyph (not a text bullet). + expect(items.every(li => li.querySelector('svg') !== null)).toBe(true) }) - it('collapse hides the list and surfaces the active item in the header; expand restores', () => { + it('collapse hides the list; expand restores; header keeps the count summary', () => { render() const header = screen.getByRole('button', { expanded: true }) fireEvent.click(header) expect(screen.queryByRole('list')).toBeNull() - // Collapsed header carries the in-progress content as the one-line hint. - expect(screen.getByText('写组件')).toBeTruthy() + // Collapsed header is title + progress only (no in-progress content hint). + expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() + expect(screen.queryByText('写组件')).toBeNull() fireEvent.click(screen.getByRole('button', { expanded: false })) expect(screen.getAllByRole('listitem')).toHaveLength(3) }) - it('collapsed header omits the hint when nothing is in progress', () => { + it('collapsed header still shows zero in-progress when nothing is active', () => { render() fireEvent.click(screen.getByRole('button', { expanded: true })) expect(screen.queryByText('都完了')).toBeNull() - expect(screen.getByText('1/1')).toBeTruthy() + expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy() }) }) @@ -71,7 +75,7 @@ describe('TodoDock', () => { render() expect(screen.queryByTestId('todo-panel')).toBeNull() act(() => { store.set({ todos: LIST }) }) - expect(screen.getByText('1/3')).toBeTruthy() + expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() // A rollback to the empty list retires the strip (the panel owns no data). act(() => { store.set({ todos: [] }) }) expect(screen.queryByTestId('todo-panel')).toBeNull() From ebb5bf4c1941cb5bddf7395a3ba6a7e718f9f9eb Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 16:25:45 +0800 Subject: [PATCH 50/56] docs(ci): starting a new failover runner needs svc.sh, not just config.sh config.sh registers the instance without starting a listener, so the procedure as written left the new runner offline and added no capacity. --- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../implemented/process/2026-07-26-ci-failover-runbook.md | 2 +- .../implemented/process/2026-07-26-ci-failover-runbook.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index de9ba10469..db702da620 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 05014454fa3e38045b89a857c346db0f897ab5a6 -2026-07-26-ci-failover-runbook.zh.md: e106a0de40799ca1c218217ea66c24068697dc53 +2026-07-26-ci-failover-runbook.md: b93c86d73f319f40706a4f9b31f448804e7b5ba8 +2026-07-26-ci-failover-runbook.zh.md: 25b83981e70070800c5a4037807d26811e55124d diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 05014454fa..b93c86d73f 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -28,7 +28,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver ## Capacity during failover -Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh`; copying `.runner`/`.credentials` verbatim makes `config.sh` refuse with "already configured". About a minute per instance. +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh`; copying `.runner`/`.credentials` verbatim makes `config.sh` refuse with "already configured". `config.sh` only registers the instance — it starts no listener, so a runner that stops there is registered and offline, adding no capacity. Install and start its service too: `sudo ./svc.sh install && sudo ./svc.sh start` (this pool is systemd-managed; a foreground `./run.sh` also works but dies with the shell). Confirm the instance reports Idle in org Settings → Actions → Runners before counting it. About a minute per instance. ### Switch back diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index e106a0de40..25b83981e7 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -28,7 +28,7 @@ Status: implemented ## 切换期间的容量 -6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`;原样拷贝 `.runner`/`.credentials` 会使 `config.sh` 以 "already configured" 拒绝。每个约一分钟。 +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`;原样拷贝 `.runner`/`.credentials` 会使 `config.sh` 以 "already configured" 拒绝。`config.sh` 只完成注册,不启动监听进程,因此停在这一步的 runner 处于已注册但离线状态,不增加任何容量。还须安装并启动其服务:`sudo ./svc.sh install && sudo ./svc.sh start`(本池由 systemd 管理;前台运行 `./run.sh` 亦可,但会随 shell 退出而终止)。确认该实例在组织 Settings → Actions → Runners 中显示 Idle 后再计入容量。每个约一分钟。 ### 切回 From 31073cc60f50fa08e098170061e3949d2c2e7eba Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:36:08 +0800 Subject: [PATCH 51/56] test(acp): refresh web-fetch tool schema snapshot --- .../tests/snapshots/web-fetch/tool-schemas.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index 1ee86b38ba..70940f8907 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", From 16598f7159c3279d607e4f6f5dc76d29f8c8c316 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 27 Jul 2026 16:36:36 +0800 Subject: [PATCH 52/56] fix: cr --- apps/web/tests/todo-display.snapshot.ts | 2 ++ .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 1 + packages/client/ui-conversation/README.zh.md | 1 + .../src/client/skeleton/TodoPanel.module.css | 1 + .../src/client/skeleton/TodoPanel.tsx | 20 +++++++++++++------ 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index f916b64b5b..ef710129d8 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -133,6 +133,8 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn const panel = document.querySelector('[data-testid="todo-panel"]') if (panel === null) throw new Error('todo panel missing from the input dock') + // Header spans are adjacent inline nodes; textContent joins "To-dos" + + // "1/3…" with no space (visual gap is CSS gap: 10px, not a text node). expect({ row: visibleText(row), rowState: row.getAttribute('data-state'), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2f67e9eac3..56923eff77 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: f6b7326916122545fc87d289cb422e644c7bae6c -README.zh.md: 55d4a743709695ba04814b4529aebac235a202d9 +README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6 +README.zh.md: 88992176165ab11050a30c7df381479796908ba2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f6b7326916..453922dafd 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -33,3 +33,4 @@ None; this package neither assembles nor sends a provider request. - **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project. +- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 55d4a74370..8899217616 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -33,3 +33,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。 +- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 5ac38c1c67..8086cbfea7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -107,6 +107,7 @@ } } +/* Figma strip is single-line; long items ellipsize with no inline expand. */ .content { min-width: 0; overflow: hidden; diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index 24764088ab..16edc423c0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -17,10 +17,16 @@ export interface TodoPanelProps { todos: readonly TodoItem[] } -/** Completed: green check ring (figma ic_ds_check_16). */ +/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if status is forged */ +function assertNever(value: never): never { + throw new Error(`unreachable todo status: ${String(value)}`) +} + +/** Status glyphs share the figma 14×14 artboard; the 16×16 `.glyph` cell centers them. */ function CompletedGlyph() { return ( -
    ').replace(/\n/g, '
    ').replace(/\|+/g, '\\|').padEnd(3, ' ') + return `${prefix}${escaped} |` +} + +/** Whether a row is the table's Markdown heading row. */ +function isTableHeadingRow(row: HTMLTableRowElement): boolean { + const cells = Array.from(row.cells) + const section = row.parentElement as HTMLTableSectionElement + const table = section.parentElement as HTMLTableElement + return (section.nodeName === 'THEAD' || table.rows[0] === row) + && cells.every(cell => cell.nodeName === 'TH') +} + +/** Map an HTML table-cell alignment to the GFM separator marker. */ +function tableBorder(cell: HTMLTableCellElement): string { + const alignment = (cell.getAttribute('align') || cell.style.textAlign || '').toLowerCase() + if (alignment === 'left') return ':---' + if (alignment === 'right') return '---:' + if (alignment === 'center') return ':---:' + return '---' +} + +turndown.addRule('tableCellWithoutSpanExpansion', { + filter: ['th', 'td'], + replacement(content, node) { + const cell = node as HTMLTableCellElement + const row = cell.parentNode as HTMLTableRowElement + // GFM cannot represent spanning cells. Ignoring colspan keeps conversion + // work and output proportional to the source instead of the numeric attribute. + return renderTableCell(content, Array.prototype.indexOf.call(row.childNodes, cell)) + }, +}) +turndown.addRule('tableRowWithoutSpanExpansion', { + filter: 'tr', + replacement(content, node) { + const row = node as HTMLTableRowElement + const border = isTableHeadingRow(row) + ? Array.from(row.cells, (cell, index) => renderTableCell(tableBorder(cell), index)).join('') + : '' + return `\n${content}${border.length > 0 ? `\n${border}` : ''}` + }, +}) + /** * Validate value constraints the schema DSL can't express: a non-blank `url`. * Throws a plain `Error` otherwise. No timeout parameter — the tool-call budget @@ -55,63 +101,142 @@ export function parseFetchArgs(args: { url: string }): { url: string } { */ const MAX_CONVERSION_DEPTH = 512 -/** Elements that never take a closing tag, so they must not count toward nesting depth. */ +/** Elements that never take a closing tag, so they do not grow the lexical stack. */ const VOID_ELEMENTS = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]) +/** Elements whose contents HTML parses as text until their matching end tag. */ +const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'noscript']) + +/** Whether a character can occur after a raw-text end-tag name. */ +function isTagBoundary(char: string | undefined): boolean { + return char === undefined || char === '>' || char === '/' || /\s/.test(char) +} + +/** Find the matching raw-text end tag without interpreting markup-like body text. */ +function findRawTextEnd(lowerHtml: string, name: string, from: number): number { + const prefix = `` characters, and only accepts a closing + * tag for the current element; malformed input therefore over-counts rather + * than hiding nesting. * * @param html - the decoded HTML body. - * @returns the deepest open-element count the scan reaches. + * @returns whether the body crosses {@link MAX_CONVERSION_DEPTH}. */ -export function htmlNestingDepth(html: string): number { - let depth = 0 - let max = 0 - for (const tag of html.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*?(\/?)>/g)) { - const [, closing, rawName = '', selfClosing] = tag - const name = rawName.toLowerCase() - if (VOID_ELEMENTS.has(name) || selfClosing === '/') continue - if (closing === '/') { - if (depth > 0) depth -= 1 - } else { - depth += 1 - if (depth > max) max = depth +function exceedsConversionDepth(html: string): boolean { + const lowerHtml = html.toLowerCase() + const openElements: string[] = [] + let offset = 0 + let inComment = false + + while (offset < html.length) { + const start = html.indexOf('<', offset) + if (inComment) { + const end = html.indexOf('-->', offset) + if (end !== -1 && (start === -1 || end < start)) { + inComment = false + offset = end + 3 + continue + } } + if (start === -1) break + if (!inComment && html.startsWith(''.repeat(600) + 'x' + expect(formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html', content: pathological }, + }, NO_CAP)).toBe(`${HEADER}${pathological}`) + const abruptlyClosedComments = '
    '.repeat(600) + 'x' + expect(formatFetchOutput({ + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html', content: abruptlyClosedComments }, + }, NO_CAP)).toBe(`${HEADER}${abruptlyClosedComments}`) + }) + + it('the preflight accepts ordinary closed, void, self-closing, quoted, and raw-text markup', () => { + const paragraphs = '

    \'>x

    '.repeat(600) + const script = `` + expect(renderHtml(`<1bad>${paragraphs}${script}`)) + .not.toContain('x