Pārlūkot izejas kodu

Merge remote-tracking branch 'origin/master' into fix/pty-handoff-grace

Chinesezjc 2 mēneši atpakaļ
vecāks
revīzija
9f695eee21
24 mainītis faili ar 495 papildinājumiem un 369 dzēšanām
  1. 6 0
      .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml
  2. 34 0
      .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
  3. 34 0
      .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md
  4. 2 2
      .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
  5. 3 1
      .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
  6. 3 1
      .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md
  7. 0 6
      .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml
  8. 0 31
      .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
  9. 0 31
      .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md
  10. 5 15
      .github/workflows/build-exe-for-python-sdk.yml
  11. 120 235
      .github/workflows/ci.yml
  12. 3 14
      .github/workflows/docs-pages.yml
  13. 3 14
      .github/workflows/e2e.yml
  14. 3 14
      .github/workflows/pi-ai-provider-e2e.yml
  15. 2 3
      .github/workflows/sandbox.yml
  16. 1 0
      AGENTS.md
  17. 1 0
      knip.json
  18. 3 1
      package.json
  19. 2 0
      packages/sdk/create-sdk/tests/link-workspace.e2e.ts
  20. 10 0
      pnpm-lock.yaml
  21. 33 0
      scripts/client-tsconfig.spec.ts
  22. 1 1
      scripts/doc-budgets.manifest.json
  23. 223 0
      scripts/wine-windows-gates.sh
  24. 3 0
      tsconfig.client.json

+ 6 - 0
.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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-26-pnpm-action-setup-for-symmetric-ci-caching.md
+2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 006360cedfd4d1e2c2b67ede98062a375e316f47
+2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: d511e7a2b89788fe8addd2cc47634742c7a87fe4

+ 34 - 0
.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md

@@ -0,0 +1,34 @@
+# Agent Note: Provision CI pnpm via pnpm/action-setup
+
+Status: implemented
+
+English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)
+
+## Problem
+
+Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned it with `corepack enable`, and five of them further repeated a hand-rolled cache setup — `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml`. The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and corepack's removal from newer Node distributions made every `corepack enable` a known future break.
+
+## Decision
+
+`pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes:
+
+- **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms.
+- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based pull-request Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm.
+- **Cache-less or persistent** (no store-cache action): native serial-windows and serial-macos plus `sandbox.yml` install from a cold or runner-local store. The self-hosted standby and failover jobs reuse their VM's persistent pnpm store without transferring a hosted cache archive.
+
+## Alternatives considered
+
+- **Keep the hand-rolled steps.** They worked, but they were drifting copies of setup boilerplate, and the corepack dependency was a known future break.
+- **Convert the enterprise jobs' caching to `cache: pnpm`.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority.
+- **Convert serial-linux's store cache.** Rejected during implementation: the original proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route.
+- **Stop at the cache-bearing workflows and leave the other `corepack enable` sites.** Rejected on review follow-up: provisioning and caching are separable concerns, and leaving corepack in the cache-less jobs kept the future break and two provisioning idioms for no benefit.
+- **Rely on the runner image's Yarn.** Rejected: the hosted image exposes Yarn 1.22 after Corepack is removed, while the generated-project e2e requires Yarn 2 or newer. A locked root dev dependency makes that coverage independent of runner image contents.
+- **A composite action wrapping action-setup + setup-node.** Rejected for now: the remaining per-job variation (node-version matrices, per-platform conditional caching, the restore-only pairing) is deliberate policy, not boilerplate — a wrapper would grow mirroring inputs or flatten a real asymmetry, and the two-line pair is already near the floor.
+
+## Consequences
+
+- The corepack dependency is gone from CI entirely; pnpm arrives via the pnpm team's official action everywhere, and the version pin stays single-sourced in `package.json`'s `packageManager` field.
+- The generated-project e2e runs the root-pinned Yarn 4 CLI instead of inheriting or silently skipping the runner image's Yarn version.
+- The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent.
+- `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry.
+- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need cache pairing or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs and serial-linux resolve and share that stable path and exact key.

+ 34 - 0
.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md

@@ -0,0 +1,34 @@
+# Agent Note: 经由 pnpm/action-setup 提供 CI 的 pnpm
+
+Status: implemented
+
+[English](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md) | 中文
+
+## 问题
+
+除 `landlock-run.yml` 外,每个安装 pnpm 的工作流都曾用 `corepack enable` 手工提供 pnpm,其中五个还各自重复着一套手写(hand-rolled)的缓存设置——`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,而 corepack 被从较新 Node 发行版中移除,使每一处 `corepack enable` 都成了已知的未来失效点。
+
+## 决策
+
+`pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态:
+
+- **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。
+- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业与基于 Wine 的拉取请求 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已能直接提供热安装。
+- **无缓存或持久化**(不使用 store 缓存 action):原生 serial-windows 和 serial-macos 加上 `sandbox.yml` 从冷 store 或 runner 本地 store 安装。自托管热备与故障切换作业复用其 VM 的持久 pnpm store,不传输托管缓存归档。
+
+## 曾考虑的替代方案
+
+- **保留手写步骤。** 它们能用,但那是会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。
+- **把企业作业的缓存也转换成 `cache: pnpm`。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。
+- **转换 serial-linux 的 store 缓存。** 实现期间否决:原提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。
+- **只转换带缓存的工作流,留下其余 `corepack enable` 站点。** 评审跟进时否决:提供 pnpm 与缓存是可分离的关注点,在无缓存作业里留下 corepack 只会保留未来失效点和两套并存的提供方式,毫无收益。
+- **依赖 runner 镜像自带的 Yarn。** 否决:Corepack 移除后,托管镜像提供的是 Yarn 1.22,而 generated-project e2e 要求 Yarn 2 或更高版本。锁定版本的根开发依赖让该项覆盖率不再受 runner 镜像内容影响。
+- **用一个组合 action 包装 action-setup + setup-node。** 暂不采纳:剩余的按作业差异(node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意的政策而非样板——包装层要么长出镜像这些差异的输入,要么抹平一处真实的不对称,而两行的组合已接近下限。
+
+## 后果
+
+- corepack 依赖已从 CI 中彻底消失;pnpm 在所有工作流中都经由 pnpm 团队的官方 action 提供,版本锁定继续单一来源于 `package.json` 的 `packageManager` 字段。
+- generated-project e2e 运行根目录锁定的 Yarn 4 CLI,既不再沿用 runner 镜像中的 Yarn 版本,也不会因此悄然跳过。
+- 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。
+- `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是从上一条缓存记录播种。
+- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业与 serial-linux 会解析并共享这一稳定路径及精确键。

+ 2 - 2
.agents/notes/implemented/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/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
+2026-07-27-wine-windows-gates-experiment.md: 640c8e455b1a35ea4ac83454227147b9979316dc
+2026-07-27-wine-windows-gates-experiment.zh.md: f30e09ca7411ef83d02faf012ce54d6c6c65dff1

+ 3 - 1
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md

@@ -18,7 +18,9 @@ Dependencies install natively on Linux with `supportedArchitectures` extended to
 
 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).
+The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The local gate is a diagnosis tool, not a routine check: run it only when investigating a known Windows-related failure; CI owns the everyday win32 signal, and [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) never selects it. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable.
+
+Five environment constraints shape CI and local execution, 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 caller'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); macOS Wine also exposes hoisted workspace links as ordinary directories, so the client test aggregate includes every package-local CSS module declaration instead of relying on project-reference realpaths; and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate).
 
 ## Measured results
 

+ 3 - 1
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md

@@ -18,7 +18,9 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表
 
 该通道靠四个杠杆保持 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` 链接在门禁前由宿主侧铺好)。
+门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。该本地门禁是诊断工具而非例行检查:仅在排查已知的 Windows 相关失败时运行;日常 win32 信号归 CI 所有,[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 也从不选择它。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。
+
+五条环境约束塑造了 CI 与本地执行,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到调用方的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);macOS Wine 也会把 hoisted workspace 链接暴露为普通目录,因此 client 测试聚合会纳入每个包自己的 CSS 模块声明,而不依赖 project-reference realpath;Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。
 
 ## 实测结果
 

+ 0 - 6
.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml

@@ -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
-2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 63e3f45ab2340ee2b732da286117e25be45bed08
-2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 2348e07d58f7f0ed39a1759cc30133c8e15dbc4a

+ 0 - 31
.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md

@@ -1,31 +0,0 @@
-# Agent Note: Use pnpm/action-setup for symmetric CI pnpm caching
-
-Status: proposed
-
-English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)
-
-## Problem
-
-Five workflows repeat a hand-rolled three-step pnpm setup — `corepack enable`, `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines total). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — is already proven in-repo in `landlock-run.yml`, and also insulates against corepack's removal from newer Node distributions.
-
-## Proposal
-
-Convert the symmetric-cache workflows to `pnpm/action-setup@v4` + `setup-node` `cache: pnpm`. Explicitly do NOT convert:
-
-- the three enterprise-runner PR jobs in `ci.yml` — they deliberately use `actions/cache/restore` only, keeping cache compression/upload off the paid latency-critical path, an asymmetry `setup-node`'s cache cannot express;
-- the Windows job, which deliberately skips the store cache.
-
-## Alternatives considered
-
-- **Keep the hand-rolled steps.** They work, but they are five drifting copies of setup boilerplate, and the corepack dependency is a known future break.
-- **Convert everything including the enterprise jobs.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority.
-
-## Acceptance criteria
-
-- The five symmetric workflows set up pnpm via the actions; one cold run per lane repopulates the new cache-key format, after which cache hit rates match the old steps.
-- The enterprise-runner PR jobs and the Windows job are untouched.
-
-## Risks
-
-- Cache-key format changes once (one cold run per lane).
-- A third-party action in more workflows; it is already trusted in-repo (`landlock-run.yml`) and is the pnpm team's official action.

+ 0 - 31
.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md

@@ -1,31 +0,0 @@
-# Agent Note: 用 pnpm/action-setup 实现对称的 CI pnpm 缓存
-
-Status: proposed
-
-[English](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md) | 中文
-
-## 问题
-
-五个工作流重复着同一套手写(hand-rolled)的三步 pnpm 设置——`corepack enable`、`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(合计约 40–60 行 YAML)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——已在仓库内的 `landlock-run.yml` 中得到验证,同时还能隔绝 corepack 被从较新 Node 发行版中移除的影响。
-
-## 提案
-
-将各对称缓存工作流改为 `pnpm/action-setup@v4` + `setup-node` `cache: pnpm`。以下明确不做转换:
-
-- `ci.yml` 中运行在企业 runner 上的三个 PR(Pull Request)作业——它们刻意只用 `actions/cache/restore`,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外,这种不对称是 `setup-node` 的缓存无法表达的;
-- Windows 作业,它刻意跳过 store 缓存。
-
-## 曾考虑的替代方案
-
-- **保留手写步骤。** 它们能用,但那是五份会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。
-- **连企业作业在内全部转换。** 否决:只恢复不上传(restore-only)的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。
-
-## 验收标准
-
-- 五个对称工作流经由上述 action 完成 pnpm 设置;每条泳道各跑一次冷运行以重建新的缓存键格式,此后缓存命中率与旧步骤持平。
-- 企业 runner 上的 PR 作业与 Windows 作业保持原样不动。
-
-## 风险
-
-- 缓存键格式变更一次(每条泳道各一次冷运行)。
-- 更多工作流引入一个第三方 action;它已在仓库内获得信任(`landlock-run.yml`),且是 pnpm 团队的官方 action。

+ 5 - 15
.github/workflows/build-exe-for-python-sdk.yml

@@ -124,9 +124,14 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
+      # setup-node's built-in pnpm store cache keys on platform AND arch, so
+      # the Linux architectures sharing runner.os stay on separate caches.
       - uses: actions/setup-node@v6
         with:
           node-version: 24
+          cache: pnpm
 
       - uses: actions/setup-python@v6
         with:
@@ -135,21 +140,6 @@ jobs:
       - name: Install Python build tooling
         run: python -m pip install uv==0.11.23
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path
-        id: pnpm-store
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      # Linux architectures share runner.os, so the cache key includes arch.
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-
-
       # Cache pkg's target Node binary; lockfile changes roll the
       # exact key while the restore prefix can seed its replacement.
       - uses: actions/cache@v4

+ 120 - 235
.github/workflows/ci.yml

@@ -58,25 +58,33 @@ jobs:
           fetch-depth: 0
           persist-credentials: false
 
+      - uses: pnpm/action-setup@v4
+
+      - uses: actions/setup-node@v6
+        with:
+          node-version: ${{ env.PRIMARY_NODE_VERSION }}
+
+      - name: Configure pnpm store path
+        id: pnpm-store
+        run: |
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
+
       # Pull requests consume the default-branch cache but do not put cache
       # 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' || github.event.pull_request.user.login == 'dependabot[bot]'
         with:
-          path: /home/runner/.local/share/pnpm/store/v11
+          path: ${{ steps.pnpm-store.outputs.path }}
           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:
-          node-version: ${{ env.PRIMARY_NODE_VERSION }}
-
-      - name: Enable corepack and install dependencies
-        run: |
-          corepack enable
-          pnpm install --frozen-lockfile
+      - name: Install (immutable)
+        run: pnpm install --frozen-lockfile
 
       - name: Run static gates
         env:
@@ -117,24 +125,33 @@ jobs:
         with:
           persist-credentials: false
 
+      - uses: pnpm/action-setup@v4
+
+      - uses: actions/setup-node@v6
+        with:
+          node-version: ${{ env.PRIMARY_NODE_VERSION }}
+
+      - name: Configure pnpm store path
+        id: pnpm-store
+        run: |
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
+
       # 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.
+      # already serves warm installs, while restoring the hosted archive
+      # would spend ~52 s pulling ~180 MB into that populated store.
       - uses: actions/cache/restore@v4
         if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
         with:
-          path: /home/runner/.local/share/pnpm/store/v11
+          path: ${{ steps.pnpm-store.outputs.path }}
           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:
-          node-version: ${{ env.PRIMARY_NODE_VERSION }}
-
-      - name: Enable corepack, install dependencies, and prepare bubblewrap
+      - name: Install dependencies and prepare bubblewrap
         run: |
-          corepack enable
           pnpm install --frozen-lockfile &
           install_pid=$!
           bash scripts/prepare-ci-bubblewrap.sh &
@@ -179,15 +196,6 @@ 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' || 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/cache/restore@v4
         with:
           path: .cache/eslint
@@ -195,13 +203,31 @@ jobs:
           restore-keys: |
             ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      - name: Enable corepack, install dependencies, and prepare bubblewrap
+      - name: Configure pnpm store path
+        id: pnpm-store
+        run: |
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
+
+      # Skipped under failover — see the coverage lane's identical rationale.
+      - uses: actions/cache/restore@v4
+        if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
+        with:
+          path: ${{ steps.pnpm-store.outputs.path }}
+          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
+          restore-keys: |
+            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
+
+      - name: Install dependencies and prepare bubblewrap
         run: |
-          corepack enable
           pnpm install --frozen-lockfile &
           install_pid=$!
           bash scripts/prepare-ci-bubblewrap.sh &
@@ -277,22 +303,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ matrix.node }}
-
-      - name: Enable corepack and resolve pnpm store path
-        id: pnpm-store
-        run: |
-          corepack enable
-          echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-${{ matrix.node }}-pnpm-
+          cache: pnpm
 
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
@@ -323,32 +339,38 @@ jobs:
   # 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
+  # on real windows-2025. This job only provisions runner state (caches,
+  # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same
+  # script the optional local gate `pnpm run check:windows-wine` runs.
+  # 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: ubuntu-latest
     name: windows node 24 / wine blocking
     timeout-minutes: 15
-    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: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
+      - name: Configure pnpm store path
+        id: pnpm-store
+        run: |
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
+
       - uses: actions/cache/restore@v4
         with:
-          path: /home/runner/.local/share/pnpm/store/v11
+          path: ${{ steps.pnpm-store.outputs.path }}
           key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
           restore-keys: |
             ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
@@ -365,144 +387,26 @@ jobs:
           path: ~/wine-debs
           key: ${{ steps.wine-cache-key.outputs.key }}
 
-      - name: Install dependencies and provision Wine concurrently
-        run: |
-          corepack enable
-
-          # 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
+      # Runner provisioning only — a developer machine installs Wine through
+      # its own package manager; the gate script assumes a wine binary and
+      # fails loud without one. 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.
+      - name: Install Wine
         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
+          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
 
-          "$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: Run the Wine Windows gates
+        run: bash scripts/wine-windows-gates.sh
 
       - name: Shut down wineserver
         if: always()
@@ -550,17 +454,26 @@ jobs:
         with:
           fetch-depth: 2
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      - name: Enable corepack and resolve pnpm store path
+      - name: Configure pnpm store path
         id: pnpm-store
         run: |
-          corepack enable
-          echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
 
       # Master refreshes the caches that pull requests restore without saving.
+      # The store cache stays a hand-rolled actions/cache step rather than
+      # setup-node's `cache: pnpm`: the enterprise pull-request jobs above
+      # restore exactly this key and path, and setup-node's built-in cache
+      # uses its own key format — converting this producer would silently
+      # starve their documented restore-only optimization.
       - uses: actions/cache@v4
         with:
           path: ${{ steps.pnpm-store.outputs.path }}
@@ -618,12 +531,14 @@ jobs:
         with:
           fetch-depth: 0
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
+      - name: Configure persistent pnpm store
+        run: echo "PNPM_CONFIG_STORE_DIR=$HOME/.local/share/pnpm/store" >> "$GITHUB_ENV"
 
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
@@ -649,13 +564,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - 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
 
@@ -681,14 +595,12 @@ jobs:
           reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
           /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      - name: Enable corepack (pnpm)
-        shell: pwsh
-        run: corepack enable
-
       # Master refreshes the small cache that pull requests restore without
       # putting package-store extraction back on the Windows critical path.
       - uses: actions/cache@v4
@@ -775,9 +687,14 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
+      # The Windows lanes deliberately skip the store cache like the required
+      # windows job; an empty cache input disables setup-node's caching.
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
+          cache: ${{ matrix.platform == 'linux' && 'pnpm' || '' }}
 
       - name: Report runner capacity
         run: >-
@@ -785,22 +702,6 @@ jobs:
           console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
           memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path
-        if: matrix.platform == 'linux'
-        id: pnpm-store
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        if: matrix.platform == 'linux'
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
-
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
 
@@ -875,9 +776,14 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
+      # Unlike the larger-runner suite, both platforms cache the store here:
+      # the consolidated topology measures cache mechanics as workload.
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
+          cache: pnpm
 
       - name: Report runner capacity
         run: >-
@@ -885,27 +791,6 @@ jobs:
           console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
           memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path (Linux)
-        if: matrix.platform == 'linux'
-        id: pnpm-store-linux
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - name: Resolve pnpm store path (Windows)
-        if: matrix.platform == 'windows'
-        id: pnpm-store-windows
-        shell: pwsh
-        run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT'
-
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store-linux.outputs.path || steps.pnpm-store-windows.outputs.path }}
-          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/cache@v4
         if: matrix.platform == 'linux'
         with:

+ 3 - 14
.github/workflows/docs-pages.yml

@@ -33,23 +33,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
-
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path
-        id: pnpm-store
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
+          cache: pnpm
 
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile

+ 3 - 14
.github/workflows/e2e.yml

@@ -61,23 +61,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: 24
-
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path
-        id: pnpm-store
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-24-pnpm-
+          cache: pnpm
 
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile

+ 3 - 14
.github/workflows/pi-ai-provider-e2e.yml

@@ -27,23 +27,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: 24
-
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path
-        id: pnpm-store
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-24-pnpm-
+          cache: pnpm
 
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile

+ 2 - 3
.github/workflows/sandbox.yml

@@ -53,13 +53,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: 24
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
 

+ 1 - 0
AGENTS.md

@@ -59,6 +59,7 @@ pnpm run typecheck
 pnpm run lint
 pnpm run duplication    # cross-file TypeScript clone detection
 pnpm run build          # tsc emits lib/types, tsdown bundles runtime
+pnpm run check:windows-wine  # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal
 pnpm run hygiene        # knip + publint + workspace constraints + NodeNext consumer check
 pnpm run doc-sync       # all documentation gates; leaf list in scripts/run-gates.ts
 pnpm run website:build  # VitePress build (doubles as dead-link check)

+ 1 - 0
knip.json

@@ -13,6 +13,7 @@
     "python/sdk-runtime"
   ],
   "ignoreDependencies": [
+    "@yarnpkg/cli-dist",
     "lightningcss"
   ],
   "workspaces": {

+ 3 - 1
package.json

@@ -40,6 +40,7 @@
     "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking",
     "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete",
     "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational",
+    "check:windows-wine": "bash scripts/wine-windows-gates.sh",
     "check:node-compat": "tsx scripts/run-gates.ts node-compat",
     "knip": "knip --treat-config-hints-as-errors",
     "publint": "tsx scripts/publint-all.ts",
@@ -113,9 +114,10 @@
     "@types/mdast": "^4.0.4",
     "@types/node": "^22.20.0",
     "@vitest/coverage-v8": "^4.1.8",
+    "@yarnpkg/cli-dist": "4.17.1",
     "eslint": "^10.4.1",
-    "execa": "^10.0.0",
     "eslint-plugin-sonarjs": "^4.1.0",
+    "execa": "^10.0.0",
     "fast-check": "^4.8.0",
     "js-yaml": "^4.2.0",
     "jscpd": "^5.0.12",

+ 2 - 0
packages/sdk/create-sdk/tests/link-workspace.e2e.ts

@@ -90,6 +90,8 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', ()
         XDG_DATA_HOME: join(cacheRoot, 'data'),
         npm_config_cache: join(cacheRoot, 'npm'),
         ...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore },
+        // A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable.
+        ...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {},
       }
       await execFileAsync(name, manager.installCommand(), {
         cwd: root,

+ 10 - 0
pnpm-lock.yaml

@@ -35,6 +35,9 @@ importers:
       '@vitest/coverage-v8':
         specifier: ^4.1.8
         version: 4.1.8(vitest@4.1.8)
+      '@yarnpkg/cli-dist':
+        specifier: 4.17.1
+        version: 4.17.1
       eslint:
         specifier: ^10.4.1
         version: 10.5.0(jiti@2.7.0)
@@ -7468,6 +7471,11 @@ packages:
   '@xterm/headless@5.5.0':
     resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==}
 
+  '@yarnpkg/cli-dist@4.17.1':
+    resolution: {integrity: sha512-2tiSQuJNl/L3QwTdrq6lKWDpkcnp9MGvCT/rIldHcbu3SWfnLdmehvt3eulX1hT7FFt1Gjfq3CesF+kvhFip6g==}
+    engines: {node: '>=18.12.0'}
+    hasBin: true
+
   accepts@2.0.0:
     resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
     engines: {node: '>= 0.6'}
@@ -12364,6 +12372,8 @@ snapshots:
 
   '@xterm/headless@5.5.0': {}
 
+  '@yarnpkg/cli-dist@4.17.1': {}
+
   accepts@2.0.0:
     dependencies:
       mime-types: 3.0.2

+ 33 - 0
scripts/client-tsconfig.spec.ts

@@ -0,0 +1,33 @@
+/** Regression coverage for source declarations owned by the client test aggregate. */
+
+import { existsSync, readdirSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import ts from 'typescript'
+import { describe, expect, it } from 'vitest'
+
+const root = fileURLToPath(new URL('..', import.meta.url))
+
+function clientCssDeclarations(): string[] {
+  const clientRoot = resolve(root, 'packages/client')
+  return readdirSync(clientRoot, { withFileTypes: true })
+    .filter(entry => entry.isDirectory())
+    .map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts'))
+    .filter(existsSync)
+    .sort()
+}
+
+describe('client TypeScript aggregate', () => {
+  it('loads package CSS declarations without relying on workspace-link realpaths', () => {
+    const configPath = resolve(root, 'tsconfig.client.json')
+    const read = ts.readConfigFile(configPath, file => ts.sys.readFile(file))
+    if (read.error !== undefined) {
+      throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n'))
+    }
+    const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root)
+    const loaded = parsed.fileNames
+      .filter(file => file.endsWith('/src/css-modules.d.ts'))
+      .sort()
+    expect(loaded).toEqual(clientCssDeclarations())
+  })
+})

+ 1 - 1
scripts/doc-budgets.manifest.json

@@ -1,5 +1,5 @@
 {
-  "AGENTS.md": 1680,
+  "AGENTS.md": 1695,
   "docs/AGENTS.md": 1150,
   "docs/architecture.md": 1800,
   "docs/cordis-primer.md": 600,

+ 223 - 0
scripts/wine-windows-gates.sh

@@ -0,0 +1,223 @@
+#!/usr/bin/env bash
+# Run the blocking Windows gates (workspace build, production site) with real
+# win-x64 Node.js under Wine — the same script the pull-request `windows` job
+# in ci.yml executes and the optional local gate `pnpm run check:windows-wine`
+# wraps. Owning rationale, fidelity limits, and measured timings:
+# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
+#
+# The working tree is never mutated: tracked plus untracked-unignored files
+# are snapshotted into a scratch directory, the Wine-specific pnpm overrides
+# (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's
+# pnpm-workspace.yaml, and the install and gates run there against the shared
+# pnpm store. The Wine prefix and the checksum-verified Windows Node zip
+# persist in .cache/wine-windows/ so reruns skip provisioning.
+#
+# Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24)
+# picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache;
+# DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection.
+
+set -euo pipefail
+
+repo_root="$(git rev-parse --show-toplevel)"
+node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}"
+cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}"
+
+export WINEDEBUG='-all'
+export WINEARCH=win64
+# Skip Wine Mono / Gecko installers: Node needs neither.
+export WINEDLLOVERRIDES='mscoree,mshtml='
+export WINEPREFIX="$cache_dir/prefix"
+
+# ---- preflight: fail loud before any expensive work --------------------
+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
+# GNU coreutils sha256sum on Linux; perl shasum ships with macOS. Both
+# accept the same "<hash>  <file>" --check input.
+checksum_tool=''
+if command -v sha256sum > /dev/null; then
+  checksum_tool='sha256sum'
+elif command -v shasum > /dev/null; then
+  checksum_tool='shasum'
+fi
+missing=()
+[ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)')
+command -v curl > /dev/null || missing+=('curl')
+command -v unzip > /dev/null || missing+=('unzip')
+[ -n "$checksum_tool" ] || missing+=('sha256sum or shasum (apt: coreutils | macOS ships shasum)')
+if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi
+command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)')
+if (( ${#missing[@]} > 0 )); then
+  printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2
+  exit 1
+fi
+
+# Verify file $2 against SHA-256 hex $1 with whichever tool preflight found.
+verify_sha256() {
+  case "$checksum_tool" in
+    sha256sum) printf '%s  %s\n' "$1" "$2" | sha256sum --check - > /dev/null ;;
+    shasum) printf '%s  %s\n' "$1" "$2" | shasum -a 256 --check - > /dev/null ;;
+  esac
+}
+
+scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")"
+cleanup() {
+  wineserver -k > /dev/null 2>&1 || true
+  if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then
+    echo "wine-windows-gates: scratch tree kept at $scratch"
+  else
+    rm -rf "$scratch"
+  fi
+}
+trap cleanup EXIT
+mkdir -p "$cache_dir" "$scratch/logs"
+
+# ---- provision Windows Node, boot Wine, snapshot + install concurrently ----
+provision_node() {
+  # Latest release of the primary line, checksum-verified against the same
+  # dist directory. Offline runs fall back to the newest cached zip, loudly.
+  local version zip
+  version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \
+    | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \
+    || true)"
+  if [ -n "$version" ]; then
+    zip="$cache_dir/node-$version-win-x64.zip"
+    if [ ! -f "$zip" ]; then
+      curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip"
+      local expected
+      expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \
+        | awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')"
+      [ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; }
+      verify_sha256 "$expected" "$zip.tmp"
+      mv "$zip.tmp" "$zip"
+    fi
+  else
+    zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)"
+    [ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; }
+    echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2
+  fi
+  unzip -q -o "$zip" -d "$scratch/node-win"
+  echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path"
+}
+
+boot_wine() {
+  "$wine_bin" wineboot --init > /dev/null 2>&1 || true
+  wineserver -w || true
+}
+
+snapshot_and_install() {
+  # Tracked + untracked-unignored files, minus agent-session litter; the
+  # existence filter drops paths staged as deleted. Then the Wine-specific
+  # install-time overrides go on the SNAPSHOT only: hoisted because Windows
+  # Node under Wine does not realpath pnpm's isolated-layout symlinks, and
+  # win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize.
+  # Neither is recorded in the lockfile, so --frozen-lockfile stays valid;
+  # --ignore-scripts skips host lifecycle scripts no gate loads.
+  git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \
+    | while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \
+    | tar -C "$repo_root" --null --files-from=- -cf - \
+    | tar -C "$scratch/tree" -xf -
+  cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF'
+
+nodeLinker: hoisted
+supportedArchitectures:
+  os: [current, win32]
+  cpu: [current, x64]
+EOF
+  (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \
+    || { tail -40 "$scratch/logs/install.log" >&2; return 1; }
+}
+
+mkdir "$scratch/tree"
+start=$SECONDS
+provision_node & node_pid=$!
+boot_wine & wine_pid=$!
+snapshot_and_install & install_pid=$!
+# Wait for EVERY child before judging any: a bare `wait` under set -e would
+# exit on the first failure and let the EXIT trap delete $scratch while the
+# other children still run inside it. Named statuses also make the report
+# point at the root cause instead of a downstream symptom.
+node_status=0; wait "$node_pid" || node_status=$?
+wine_status=0; wait "$wine_pid" || wine_status=$?
+install_status=0; wait "$install_pid" || install_status=$?
+provision_failed=0
+report_provision() {
+  if (( $2 != 0 )); then
+    echo "wine-windows-gates: FAILED $1 (exit $2)" >&2
+    provision_failed=$2
+  fi
+}
+report_provision 'Windows Node provisioning' "$node_status"
+report_provision 'wineboot' "$wine_status"
+report_provision 'workspace snapshot + pnpm install' "$install_status"
+if (( provision_failed != 0 )); then exit "$provision_failed"; fi
+node_win="$(cat "$scratch/node-win-path")"
+echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))"
+
+# ---- resolve entrypoints, lay the vue link, smoke ------------------------
+# Node under Wine cannot attach stdio to pipes the caller owns (Socket open
+# EBADF at bootstrap), so every invocation routes stdio through a file.
+wine_node() {
+  local log="$1"
+  shift
+  local status=0
+  "$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$?
+  return "$status"
+}
+
+cd "$scratch/tree"
+tsc_js='node_modules/typescript/bin/tsc'
+tsdown_js='node_modules/tsdown/dist/run.mjs'
+vitepress_js='node_modules/vitepress/bin/vitepress.js'
+[ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js'
+for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do
+  [ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; }
+done
+# VitePress links vue into the site's node_modules at build time; Wine cannot
+# CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones.
+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
+
+wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
+cat "$scratch/logs/smoke.log"
+grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; }
+
+# ---- the two blocking surfaces, 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.
+build_gate() {
+  wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $?
+  wine_node "$scratch/logs/tsdown.log" "$tsdown_js"
+}
+site_gate() {
+  cd website
+  wine_node "$scratch/logs/site.log" "../$vitepress_js" build .
+}
+
+start=$SECONDS
+build_gate & build_pid=$!
+site_gate & site_pid=$!
+build_status=0
+wait "$build_pid" || build_status=$?
+site_status=0
+wait "$site_pid" || site_status=$?
+elapsed=$((SECONDS - start))
+
+report() {
+  local label="$1" status="$2"
+  shift 2
+  if (( status == 0 )); then
+    echo "wine-windows-gates: PASS $label (${elapsed}s window)"
+  else
+    echo "== FAILED $label (exit $status) ==" >&2
+    for log in "$@"; do tail -n 200 "$log" >&2 || true; done
+  fi
+}
+report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log"
+report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log"
+if (( build_status != 0 )); then exit "$build_status"; fi
+exit "$site_status"

+ 3 - 0
tsconfig.client.json

@@ -14,6 +14,9 @@
     "types": ["node"]
   },
   "include": [
+    // Source-subpath test imports can arrive through workspace links whose
+    // realpath semantics vary by host. Load package CSS declarations directly.
+    "packages/client/*/src/css-modules.d.ts",
     "packages/client/*/tests/**/*.ts",
     "packages/client/*/tests/**/*.tsx",
     "packages/client/tsdown.client.ts",