mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-01 04:00:45 +00:00
Merge remote-tracking branch 'origin/master' into xtr/session-projection-migrations
This commit is contained in:
+2
-2
@@ -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/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 8731528b9ae600bb8bfe12738669f3a84c11a06b
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 755b4bd7ddbe9b88b4f40b8a3ae7419f746b8dde
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: c152345772826ec4e2dbfd238726c429418c7897
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ea5e457afd761cb5071f8b584ef10fa7ffaa8210
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/bin.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime-<platform>-<arch>` under `dist-exe/` and copy it into the runtime directory. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry.
|
||||
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` and the `build-exe` label can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64.
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64.
|
||||
|
||||
### Python SDK distribution: two carriers, exe for production, node for development
|
||||
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/bin.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime-<platform>-<arch>` 写入 `dist-exe/` 并拷回运行时目录。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。
|
||||
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。
|
||||
|
||||
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
|
||||
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-07-23-client-plugin-loading-model.md
|
||||
2026-07-23-client-plugin-loading-model.md: bd6f6e58c571102afc789ef57085db1e302158cc
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 256b57102bbec6f793d48d0bdaf60445b194ecdf
|
||||
2026-07-23-client-plugin-loading-model.md: 0fe4e86410f3b313ec5a31099d5a6ed1f828585b
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 386b0edb722d8cedd9325c941f9b392b8cdc8ae2
|
||||
|
||||
@@ -72,7 +72,7 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a
|
||||
|
||||
Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
|
||||
|
||||
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle and optional-map stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a script/map mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
|
||||
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a bundle mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; it reads the current source map as part of that new artifact snapshot, while a map-only write does not remount unchanged executable code. When the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one bundle stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn bundle read self-heals: its stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
|
||||
|
||||
On the browser side, the driver reloads one plugin per frame, serialized:
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
|
||||
|
||||
热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
|
||||
|
||||
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 与可选 map 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。脚本/map 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
|
||||
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它会在新产物快照中一并读取当前 source map,而仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
|
||||
|
||||
浏览器侧,驱动插件每帧重载一个插件,串行执行:
|
||||
|
||||
|
||||
@@ -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/architecture/2026-08-20-webworker-node-face.md
|
||||
2026-08-20-webworker-node-face.md: 41a30dedc7df9a882fbc1d8d3e3583c0a3602d81
|
||||
2026-08-20-webworker-node-face.zh.md: b57481335808f3e1a764da123a11ea74ba6cf371
|
||||
2026-08-20-webworker-node-face.md: 05c3bfe7fa07b34189229f6454be597d4956226a
|
||||
2026-08-20-webworker-node-face.zh.md: 0a0f4badff192f02e962cfb38b64ef9d89fcd420
|
||||
|
||||
@@ -10,7 +10,7 @@ The worker runs the web profile's Cordis configuration byte for byte — no work
|
||||
|
||||
## Decision
|
||||
|
||||
**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly.
|
||||
**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly. The shim includes `process.title`: packages such as `@xterm/headless` use that property's presence to select their Node path, while omitting it makes a dedicated Worker look like a browser Window and reaches DOM-only globals.
|
||||
|
||||
**VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning.
|
||||
|
||||
@@ -31,4 +31,5 @@ The worker runs the web profile's Cordis configuration byte for byte — no work
|
||||
- `read-only` and `workspace-write` interpret the native Landlock launcher protocol and enforce per-process grants at the VFS frame gate; `danger-full-access` keeps the direct process path. The [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md) owns the narrower meaning of `full` in this execution world.
|
||||
- The Node-host ladder test (`tests/node/child-process.spec.ts`) is registered windows-unsupported: the ladder's win32 kill rung is taskkill-by-real-pid, undeliverable to a process-table pid, while the worker itself always reports `linux`.
|
||||
- Output is incremental but not streamed: programs write into sinks forwarded as `data` events, and a pipeline stage completes before the next starts.
|
||||
- `tests/node/process-shim.spec.ts` pins the Node detection field independently from the test runner's ambient Node process.
|
||||
- The runtime's tests mirror `src/` (`tests/node/`, `tests/shell/`, `tests/storage/`, …), so each shim family owns its behavior cases beside the oracle-diff suites.
|
||||
|
||||
@@ -10,7 +10,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属
|
||||
|
||||
## 决定
|
||||
|
||||
**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。
|
||||
**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。Shim 包含 `process.title`:`@xterm/headless` 等包通过该属性是否存在来选择 Node 路径;缺少它会让 dedicated Worker 被误判为浏览器 Window,进而访问仅适用于 DOM 的全局对象。
|
||||
|
||||
**VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。
|
||||
|
||||
@@ -31,4 +31,5 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属
|
||||
- `read-only` 与 `workspace-write` 解释 native Landlock launcher 协议,并在 VFS 帧闸口执行逐进程授权;`danger-full-access` 保持直接进程路径。[Watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)拥有该执行世界中 `full` 的更窄含义。
|
||||
- Node 宿主的阶梯测试(`tests/node/child-process.spec.ts`)登记为 windows 不支持:阶梯的 win32 kill 梯级是按真 pid 的 taskkill,对进程表 pid 不可投递,而 worker 自身恒报 `linux`。
|
||||
- 输出增量但不流式:程序写入的 sink 以 `data` 事件转发,一个管道阶段完成后下一阶段才开始。
|
||||
- `tests/node/process-shim.spec.ts` 独立于测试运行器自带的 Node process,钉住 Node 环境识别字段。
|
||||
- 运行时的测试镜像 `src/`(`tests/node/`、`tests/shell/`、`tests/storage/`……),每个垫片族在 oracle-diff 套件旁拥有自己的行为用例。
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
|
||||
2026-08-20-webworker-pack-lowering-and-preview.md: 1ec8fb050445b0a90d8fbf0d97c9ef10cb28b287
|
||||
2026-08-20-webworker-pack-lowering-and-preview.zh.md: 86da66560b509b38ba3ffa49d58035f3e5a173f1
|
||||
2026-08-20-webworker-pack-lowering-and-preview.md: 24dd5ba6e3eb633253321b219781b537f1faf429
|
||||
2026-08-20-webworker-pack-lowering-and-preview.zh.md: 5ee59e9a508767be928e2de804514b6357fe4ffd
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ The browser worker can neither compile modules at load nor be served by the prod
|
||||
|
||||
## Decision
|
||||
|
||||
**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there.
|
||||
**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; the same parse feeds reachability with statically named imports, re-exports, and dynamic imports, calls through `require`, and module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import. Stored results, CommonJS-obtained `createRequire`, computed request names, and other bases stay runtime-only; targets reachable only through those forms require image entry seeds. Inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there.
|
||||
|
||||
**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. Plugin combo scripts and maps travel through the tunnel; the page-side loader embeds each tunnel-only map as a Base64 data URL before executing its script Blob, preserving indexed-map component names in DevTools without another object-URL lifetime. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
|
||||
|
||||
@@ -37,7 +37,7 @@ Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental
|
||||
## Consequences
|
||||
|
||||
- `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed).
|
||||
- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts.
|
||||
- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, the transform semantic suite pins `createRequire` request discovery, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts.
|
||||
- The transform corpus imports every built bundle through Node before comparing its lowered exports. Its pinned exemptions name the actual non-importable bundle and fail when one becomes importable: after Win32 process primitives became the Koffi type owner, `win32-process` carries the duplicate-type exemption and `sandbox-windows-acl` does not.
|
||||
- The served `<base href="/">` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base.
|
||||
- The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads.
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
|
||||
## 决定
|
||||
|
||||
**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。
|
||||
**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;同一次解析会把具名静态 import、re-export 与动态 import、经 `require` 发起的调用,以及通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用送入可达性遍历。保存下来的结果、经 CommonJS 获取的 `createRequire`、计算得到的请求名称与其他基准只在运行时解析;只能通过这些形式触达的目标需要镜像入口种子。worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。
|
||||
|
||||
**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。插件 combo 脚本与 map 都通过 tunnel;页面侧 loader 会在执行脚本 Blob 前,把每个仅 tunnel 可达的 map 内嵌为 Base64 data URL,从而不依赖另一条 object URL 的生命周期,并在 DevTools 中保留 indexed map 的组件名称。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
## 后果
|
||||
|
||||
- `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。
|
||||
- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,`apps/web/tests/preview-boot.e2e.ts` 在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。
|
||||
- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,transform 语义套件钉住 `createRequire` 请求发现,`apps/web/tests/preview-boot.e2e.ts` 则在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。
|
||||
- 转换 corpus 会先通过 Node 导入每个已构建 bundle,再比较 lowered export。固定豁免会点名真正不可导入的 bundle,并在其恢复可导入时失败:`win32-process` 是 Koffi 类型 owner 并承担重复类型豁免;`sandbox-windows-acl` 可正常导入,不承担该豁免。
|
||||
- served 的 `<base href="/">` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。
|
||||
- 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。
|
||||
|
||||
+6
@@ -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/architecture/2026-08-25-persistence-latency-and-page-size.md
|
||||
2026-08-25-persistence-latency-and-page-size.md: 27eb58cc551f01c48361a3af3224eb8b12592a00
|
||||
2026-08-25-persistence-latency-and-page-size.zh.md: 24ab1835cc313cd617d665a0c52a399d505069ea
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# Agent Note: Persistence compression latency and SQLite page size
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-25-persistence-latency-and-page-size.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The physical persistence optimizations need to reduce retained storage without moving disproportionate work into full writes, reads, or session forks. The original 105-session corpus showed that JSONL level-19 compression made full writes and forks more than twice as slow. The earlier SQLite page-size experiment predated shared-dictionary row compression and showed negligible savings, so it did not establish the best page size for the current row distribution.
|
||||
|
||||
The decision needs evidence from more varied sessions, including long event streams and payloads outside the original corpus. The expanded corpus contains 501 real sessions, 16,153,332 logical events, and 2,002,145,570 bytes of serialized event data.
|
||||
|
||||
## Decision
|
||||
|
||||
### Storage encoding stays physical and independently decodable
|
||||
|
||||
JSONL stores strictly increasing `sourceEventSeqs` as mixed scalar values and inclusive ranges; other orders remain verbatim. SQLite stores the same arrays as tagged zigzag-delta or `(start, count)` varints, choosing the smaller encoding. Both readers restore the original `number[]` before exposing an event.
|
||||
|
||||
SQLite uses an internal integer `sessions.id` and keeps the public session id once in `sessions.session_key`, so event rows and their primary key do not repeat a text identifier. Each `events.data` value remains independently decodable: the writer tries level-3 Zstandard with the packaged 64 KiB raw-content dictionary and retains SQLite text when compression is not smaller. The dictionary bytes are part of schema 19 and a test pins their SHA-256 digest; replacing them requires another schema-version bump.
|
||||
|
||||
### JSONL uses the standard Zstandard level
|
||||
|
||||
The JSONL writer keeps one checksummed Zstandard frame per durable append batch but uses the compressor's standard level. Lossless `sourceEventSeqs` range encoding remains active. Frames stay independently decodable for suffix reads and torn-tail recovery; only the expensive level-19 search is removed.
|
||||
|
||||
### New SQLite databases use 64 KiB pages
|
||||
|
||||
The SQLite provider sets `page_size=65536` before initializing a pristine schema-19 database. An established schema-19 database retains its current page size because SQLite ignores the pragma after allocation.
|
||||
|
||||
The page size is part of schema 19's fixed physical layout and is applied through the package's closed SQL resources like the other fixed SQLite pragmas.
|
||||
|
||||
### Expanded benchmark
|
||||
|
||||
Each candidate was rebuilt five times from the same 501-session corpus with 512-event append batches. Their order rotates between rounds so every candidate occupies each run position once. Each build runs three complete and suffix-read sweeps. For each displayed metric, the highest and lowest build are discarded and the remaining three values are averaged. Complete and suffix read times cover one sweep over all sessions, and fork time covers all 501 sessions.
|
||||
|
||||
| Backend | Stored size | Full write | Full read | Suffix read | Fork |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| JSONL `master` | 172.43 MB | 200.902 s | 8.033 s | 24.479 s | 72.670 s |
|
||||
| JSONL with provenance ranges | 148.15 MB (-14.1%) | 197.281 s (-1.8%) | 7.799 s (-2.9%) | 24.582 s (+0.4%) | 72.308 s (-0.5%) |
|
||||
| JSONL with provenance ranges and level 19 | 130.22 MB (-24.5%) | 329.442 s (+64.0%) | 7.764 s (-3.3%) | 24.454 s (-0.1%) | 166.177 s (+128.7%) |
|
||||
| SQLite `master` (schema 17) | 438.31 MB | 69.632 s | 8.211 s | 0.546 s | 64.290 s |
|
||||
| SQLite with all physical optimizations and 64 KiB pages | 233.18 MB (-46.8%) | 87.656 s (+25.9%) | 9.155 s (+11.5%) | 0.575 s (+5.3%) | 79.417 s (+23.5%) |
|
||||
|
||||
Relative to standard-level frames with provenance ranges, level 19 saves another 12.1% of the JSONL bytes but increases full-write time by 67.0% and fork time by 129.8%. Its complete and suffix reads change by -0.4% and -0.5%. The extra search therefore benefits retained size without improving the latency-sensitive operations enough to offset its repeated encoding cost.
|
||||
|
||||
An otherwise identical SQLite build isolates the page-size effect: 4 KiB pages use 256.97 MB and 64 KiB pages use 233.18 MB (-9.26%). The `events` table's unused page bytes fall from 30.25 MB to 6.95 MB, while the index changes from 5.92 MB to 6.03 MB. In the paired run, full write, full read, and suffix read change by -0.5%, -0.4%, and -3.8%; fork changes by -14.8%. The space gain therefore comes from better large-row page utilization rather than a smaller index or omitted data, without a measured latency regression.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep JSONL level 19.** Rejected. On the expanded corpus it saves another 12.1% relative to default-level frames but increases full-write time by 67.0% and fork time by 129.8%, while complete and suffix reads differ by less than 1%. Default-level frames plus provenance ranges retain a 14.1% size reduction relative to master without a material latency regression.
|
||||
|
||||
**Compress one whole JSONL log as a single frame.** Rejected. It improves cross-batch compression but makes suffix reads decompress from the start and removes batch-local torn-tail recovery.
|
||||
|
||||
**Keep 4 KiB SQLite pages.** Rejected for pristine databases. The current compressed-row distribution retains 9.26% more bytes because large compressed records leave more unusable space across 4 KiB B-tree pages. Existing databases keep their page size to avoid a historical rewrite.
|
||||
|
||||
**Remove ROWID from `events`.** Rejected. The composite primary key becomes the table B-tree key and repeats through internal pages; the 105-session comparison produced a larger database than ordinary ROWID tables.
|
||||
|
||||
**Deduplicate event content.** Rejected. Message restatements and tool arguments can be reconstructed only under assumptions that compaction, retries, and pruning may invalidate. Physical compression preserves every event without adding reconstruction semantics.
|
||||
|
||||
**Use per-session SQLite files or DuckDB.** Rejected for the hot store. Per-session files lose cross-session queries, while DuckDB's OLAP write model fits cold batch analysis rather than durable append batches and low-latency suffix reads.
|
||||
|
||||
## Consequences
|
||||
|
||||
JSONL keeps the low-cost provenance optimization without the level-19 write and fork penalty. SQLite exchanges approximately 5–26% more time across the measured operations for a 46.8% retained-size reduction; its full write remains materially faster than JSONL, and its suffix read remains much faster. Its complete read and fork are slightly slower than default-level JSONL on this expanded corpus.
|
||||
|
||||
New SQLite databases use 64 KiB WAL frames and cache pages. Small databases may reserve more bytes for sparsely populated schema and metadata pages, while the measured multi-session workload gains substantially better `events` page utilization. Schema 19 rejects every other schema version rather than migrating it.
|
||||
|
||||
## Related
|
||||
|
||||
- [sqlite-physical-chunk-row-compression](2026-08-18-sqlite-physical-chunk-row-compression.md) — owns the packed row model; its earlier page-size conclusion applies to the pre-dictionary layout.
|
||||
- [zstandard-jsonl-session-logs](2026-07-19-zstandard-jsonl-session-logs.md) — owns the checksummed frame-per-batch container and the standard compressor-level policy restored here.
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# Agent Note: 持久化压缩延迟与 SQLite page size
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-25-persistence-latency-and-page-size.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
物理持久化优化需要减少保留存储,同时不能把不成比例的工作转移到完整写入、读取或会话 fork。原有的 105 会话语料显示,JSONL level-19 压缩会让完整写入与 fork 耗时增加一倍以上。此前的 SQLite page-size 实验早于共享字典行压缩,所得空间收益可以忽略,因此无法确定当前行分布的最佳 page size。
|
||||
|
||||
该决策需要来自更多样会话的证据,包括长事件流与原语料之外的 payload。扩展后的语料包含 501 个真实会话、16,153,332 个逻辑事件与 2,002,145,570 字节序列化事件数据。
|
||||
|
||||
## 决策
|
||||
|
||||
### 存储编码保持为物理层行为并可独立解码
|
||||
|
||||
JSONL 把严格递增的 `sourceEventSeqs` 存为标量值与闭区间的混合数组,其他顺序保持原样。SQLite 把同一数组存为带 tag 的 zigzag-delta 或 `(start, count)` varint,并选择更小的编码。两个读取方都会在暴露事件前还原原始 `number[]`。
|
||||
|
||||
SQLite 使用内部整数 `sessions.id`,并只在 `sessions.session_key` 中保留一次公开会话 id,使事件行及其主键不再重复文本标识。每个 `events.data` 值仍可独立解码:写入方尝试用打包的 64 KiB raw-content 字典执行 level-3 Zstandard 压缩,结果不更小时保留 SQLite 文本。字典字节属于 schema 19,测试固定其 SHA-256 摘要;替换字典需要再次提升 schema 版本。
|
||||
|
||||
### JSONL 使用 Zstandard 标准级别
|
||||
|
||||
JSONL 写入方继续为每个持久 append 批次写入一个带 checksum 的 Zstandard frame,但使用压缩器的标准级别。无损 `sourceEventSeqs` 区间编码继续生效。各 frame 仍可独立解码,以支持后缀读取与撕裂尾部恢复;只移除昂贵的 level-19 搜索。
|
||||
|
||||
### 新建 SQLite 数据库使用 64 KiB page
|
||||
|
||||
SQLite 提供方在初始化全新 schema-19 数据库前设置 `page_size=65536`。SQLite 在 page 已分配后会忽略该 pragma,因此已有 schema-19 数据库保留其当前 page size。
|
||||
|
||||
Page size 属于 schema 19 的固定物理布局,并与其他固定 SQLite pragma 一样通过包内封闭的 SQL 资源应用。
|
||||
|
||||
### 扩展基准
|
||||
|
||||
每个候选方案都从同一份 501 会话语料独立重建五次,每个 append 批次包含 512 个事件。各轮轮换执行顺序,使每个候选方案在每个运行位置各出现一次。每次重建执行三轮完整读取与后缀读取。下表中的每项指标都去掉最高与最低的一次重建,再平均其余三次。完整读取与后缀读取耗时覆盖对全部会话的一轮扫描,fork 耗时覆盖全部 501 个会话。
|
||||
|
||||
| 后端 | 存储大小 | 完整写入 | 完整读取 | 后缀读取 | Fork |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| JSONL `master` | 172.43 MB | 200.902 s | 8.033 s | 24.479 s | 72.670 s |
|
||||
| JSONL + 来源区间 | 148.15 MB (-14.1%) | 197.281 s (-1.8%) | 7.799 s (-2.9%) | 24.582 s (+0.4%) | 72.308 s (-0.5%) |
|
||||
| JSONL + 来源区间 + level 19 | 130.22 MB (-24.5%) | 329.442 s (+64.0%) | 7.764 s (-3.3%) | 24.454 s (-0.1%) | 166.177 s (+128.7%) |
|
||||
| SQLite `master`(schema 17) | 438.31 MB | 69.632 s | 8.211 s | 0.546 s | 64.290 s |
|
||||
| SQLite + 全部物理优化 + 64 KiB page | 233.18 MB (-46.8%) | 87.656 s (+25.9%) | 9.155 s (+11.5%) | 0.575 s (+5.3%) | 79.417 s (+23.5%) |
|
||||
|
||||
相对使用来源区间的标准级别 frame,level 19 可再减少 12.1% 的 JSONL 字节,但会让完整写入增加 67.0%、fork 增加 129.8%;完整读取与后缀读取分别变化 -0.4% 与 -0.5%。因此,更深入的搜索只改善保留体积,无法通过延迟敏感操作的收益抵消反复付出的编码成本。
|
||||
|
||||
其余条件相同的 SQLite 重建可单独观察 page-size 影响:4 KiB page 使用 256.97 MB,64 KiB page 使用 233.18 MB(-9.26%)。`events` 表的 page 内未使用字节从 30.25 MB 降至 6.95 MB,索引则从 5.92 MB 变为 6.03 MB。在该成对运行中,完整写入、完整读取与后缀读取分别变化 -0.5%、-0.4% 与 -3.8%,fork 变化 -14.8%。因此,空间收益来自更高的大记录 page 利用率,而不是索引缩小或数据省略,并且没有测得延迟退化。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留 JSONL level 19。** 不予采用。在扩展语料上,它相对默认级别 frame 可再减少 12.1%,却让完整写入增加 67.0%、fork 增加 129.8%,而完整读取与后缀读取的差异都不足 1%。默认级别 frame 配合来源区间后,相对 master 仍能缩小 14.1%,且没有实质性延迟退化。
|
||||
|
||||
**把整份 JSONL 日志压成单个 frame。** 不予采用。该方案可改善跨批次压缩,但后缀读取必须从头解压,也会失去按批次恢复撕裂尾部的能力。
|
||||
|
||||
**新建 SQLite 数据库继续使用 4 KiB page。** 不予采用。当前压缩行分布会在 4 KiB B-tree page 之间留下更多不可用空间,使保留字节增加 9.26%。已有数据库保留其 page size,避免改写历史数据。
|
||||
|
||||
**从 `events` 移除 ROWID。** 不予采用。复合主键会成为表 B-tree 键并在内部 page 中重复;105 会话对比所得数据库大于使用普通 ROWID 的表。
|
||||
|
||||
**对事件内容去重。** 不予采用。消息复述与工具参数只能在依赖重建假设时删除,而 compaction、重试和修剪可能让这些假设失效。物理压缩保留每个事件,不增加重建语义。
|
||||
|
||||
**使用逐会话 SQLite 文件或 DuckDB。** 不用于热存储。逐会话文件会失去跨会话查询,DuckDB 的 OLAP 写入模型则更适合冷批量分析,而不是持久 append 批次与低延迟后缀读取。
|
||||
|
||||
## 后果
|
||||
|
||||
JSONL 保留低成本来源优化,同时避开 level-19 的写入与 fork 代价。SQLite 以实测各项操作约 5–26% 的额外耗时换取 46.8% 的保留体积缩减;其完整写入仍明显快于 JSONL,后缀读取也仍快得多。在这份扩展语料上,完整读取与 fork 略慢于默认级别 JSONL。
|
||||
|
||||
新建 SQLite 数据库使用 64 KiB WAL frame 与 cache page。小型数据库可能为稀疏的 schema 与元数据 page 预留更多字节,而实测的多会话工作负载显著改善了 `events` page 利用率。Schema 19 会拒绝其他所有 schema 版本,而不是迁移它们。
|
||||
|
||||
## 相关资料
|
||||
|
||||
- [sqlite-physical-chunk-row-compression](2026-08-18-sqlite-physical-chunk-row-compression.zh.md) — 定义打包行模型;其此前的 page-size 结论适用于共享字典之前的布局。
|
||||
- [zstandard-jsonl-session-logs](2026-07-19-zstandard-jsonl-session-logs.zh.md) — 定义带 checksum 的按批次 frame 容器,以及本笔记恢复的标准压缩级别策略。
|
||||
+6
@@ -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/architecture/2026-08-26-models-page-extension-slots.md
|
||||
2026-08-26-models-page-extension-slots.md: 9983c669b7838de00d49a634a54e5d115ef4945a
|
||||
2026-08-26-models-page-extension-slots.zh.md: 639654ea4b912d43a95f0a9295969523f4e1c5f8
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Models-page extension slots
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-26-models-page-extension-slots.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Provider sign-in for the pi-ai catalog (GitHub Copilot, OpenAI accounts) is moving out of the product into an optional out-of-tree plugin for provider terms-of-service reasons. The plugin needs its sign-in button and attempt UI inside the Models page's provider cards — the surface where a user meets a provider — but `ui-settings-models` rendered its cards from closed code: the only integration path was editing this package, which an external plugin cannot do, and the page's one open seam (`settings.section`) can only add a whole separate page.
|
||||
|
||||
## Decision
|
||||
|
||||
`ui-settings-models` declares two SlotMap seats in `src/client/slot-contract.ts`, claims them as `children` of its `settings.section` registration, and re-exports their types from `./client` so an out-of-tree plugin can merge them with a type-only import.
|
||||
|
||||
`settings.models.provider-card` is `keyed` with `entryKey = ConfigurableProviderView.settingsNs`: one registration under an adapter family's settings namespace receives every card of that family — shipped catalog routes, adopted directory rows, and hand-declared routes alike — while the section never interprets the key. The key domain stays the open string space (no `keyProps` table) because hand-declared route ids are user-chosen at runtime. The seat dispatches on every card that shows a directory row: a saved row's card, its first-run setup posture, and the add-provider draft (its dormant row, `configured: false` in practice), which is where sign-in matters most — the user has just met the provider and holds no key. The hand-declared draft card has no directory row before saving and dispatches nothing. Owner props carry the row's `ConfigurableProviderView`, its `configured` join, and its confirmed api-key credential state (`keyConfigured`, which the first consumer uses to withhold sign-in beside a stored key); nothing more has a current consumer.
|
||||
|
||||
`settings.models.footer` is a `list` seat after the rows and the add controls, for section-level extension content such as orphaned-record management.
|
||||
|
||||
Without registrants both seats render nothing, so the shipped page is pixel-identical to before.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A `list` seat with self-filtering registrants instead of keying.** Every registrant would render (and return null) on every card, and two plugins could silently interleave UI inside one family's cards. Keying by namespace gives one accountable extension owner per adapter family and zero wasted dispatches, and reuses the exact pairing rationale of `settings.plugin.item`.
|
||||
|
||||
**Keying by provider route id.** Route ids are dynamic — hand-declared routes are named by users at runtime — so a plugin could not register ahead of the rows it wants and would have to churn registrations as the directory changes.
|
||||
|
||||
**A `chain` seat replacing the whole card body.** No current consumer needs to replace the editor; the sign-in surface is additive. A takeover contract would also make the section's layout a compatibility surface. A chain can still be added later without disturbing these seats.
|
||||
|
||||
**Keeping the sign-in UI wired inside `ui-settings-models` (the pre-plugin design).** Ships the terms-of-service-sensitive surface in the product, which is the outcome this extension point exists to avoid.
|
||||
|
||||
## Consequences
|
||||
|
||||
An out-of-tree plugin can now integrate per-family card UI into the Models page with no product edits; `llm-pi-ai-oauth` is the first consumer. The cost is a public contract: `ProviderCardExtrasOwnerProps` exposes `ConfigurableProviderView` at the `./client` boundary, and the dispatch sites (saved card, setup posture, add draft, footer) become behavior extensions rely on. Per adapter family the keyed cell renders one owner at a time: a second registration under the same namespace at the same priority is refused by the registry, while a different priority deliberately shadows it (the lowest-priority entry renders) — the slot kit's standard override channel, never a silent merge.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Models 页扩展插槽
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-26-models-page-extension-slots.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
出于提供方服务条款的考虑,pi-ai catalog 的提供方登录(GitHub Copilot、OpenAI 账号)正从产品中移出,改由一个可选的仓库外插件承担。该插件需要把登录按钮与登录过程 UI 放进 Models 页的提供方卡片——用户与提供方相遇的界面——但 `ui-settings-models` 的卡片由封闭代码渲染:唯一的集成途径是修改本包,外部插件做不到;页面仅有的开放缝隙(`settings.section`)只能新增一整个独立页面。
|
||||
|
||||
## Decision
|
||||
|
||||
`ui-settings-models` 在 `src/client/slot-contract.ts` 声明两个 SlotMap 席位,在其 `settings.section` 注册中以 `children` 认领它们,并从 `./client` 再导出其类型,使仓库外插件通过 type-only import 即可获得类型合并。
|
||||
|
||||
`settings.models.provider-card` 为 `keyed`,`entryKey = ConfigurableProviderView.settingsNs`:以某适配器家族的 settings namespace 注册一次,即可收到该家族的全部卡片——内置 catalog 路由、从目录采纳的行、手工声明的路由一视同仁——而分区从不解释这个 key。键域保持开放字符串空间(不设 `keyProps` 表),因为手工声明的路由 id 由用户在运行时命名。该席位在每张展示目录行的卡片上分发:已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡(其休眠行,实际为 `configured: false`)——草稿卡正是登录价值最大的时刻:用户刚遇到该提供方、手中还没有密钥。手工声明的草稿卡在保存前没有目录行,不分发。Owner props 携带该行的 `ConfigurableProviderView`、其 `configured` 合并结果与已确认的 api-key 凭据状态(`keyConfigured`,首个消费者用它在已存密钥旁抑制登录入口);更多字段没有现役消费者。
|
||||
|
||||
`settings.models.footer` 为 `list` 席位,位于行列表与新增控件之后,承载孤儿记录管理这类分区级扩展内容。
|
||||
|
||||
没有注册方时两个席位均不渲染,产品页面与之前逐像素一致。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**用 `list` 席位、由注册方自行筛选,替代按键分发。**每个注册方都会在每张卡片上渲染(再返回 null),且两个插件可能在同一家族的卡片里静默交错 UI。按 namespace 分发让每个适配器家族有唯一可问责的扩展所有者、零浪费分发,并完全复用 `settings.plugin.item` 的配对理由。
|
||||
|
||||
**按提供方路由 id 分发。**路由 id 是动态的——手工声明的路由由用户在运行时命名——插件无法先于目标行注册,还得随目录变化反复重注册。
|
||||
|
||||
**用 `chain` 席位整体接管卡片。**没有现役消费者需要替换编辑器;登录界面是加法。接管契约还会让分区布局成为兼容面。将来仍可在不动这两个席位的前提下追加 chain。
|
||||
|
||||
**把登录 UI 继续织在 `ui-settings-models` 里(插件化之前的设计)。**会把服务条款敏感的界面随产品发布,而这正是本扩展点要避免的结果。
|
||||
|
||||
## Consequences
|
||||
|
||||
仓库外插件现在无需改动产品即可把按家族的卡片 UI 集成进 Models 页;`llm-pi-ai-oauth` 是首个消费者。代价是一份公开契约:`ProviderCardExtrasOwnerProps` 在 `./client` 边界暴露 `ConfigurableProviderView`,且各分发位点(已保存卡片、setup 形态、新增草稿、footer)成为扩展方依赖的行为。每个适配器家族的 keyed 单元格同一时刻只渲染一个所有者:同一 namespace 下同 priority 的第二次注册会被注册表拒绝,不同 priority 则是刻意的遮蔽(最低 priority 的条目渲染)——这是 slot 套件的标准覆盖通道,绝非静默合并。
|
||||
@@ -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/bug-fix/2026-08-26-composer-gesture-editable-gate.md
|
||||
2026-08-26-composer-gesture-editable-gate.md: 9b01a79d8d8a546641eaf87040d7a19fc5b4d5bc
|
||||
2026-08-26-composer-gesture-editable-gate.zh.md: b5dc8d36c7b195a152d32e92fac7ae36eea880d9
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Composer e2e gestures gate on the contenteditable attribute
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-26-composer-gesture-editable-gate.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Two Playwright gesture semantics silently changed when the composer became a Lexical `contenteditable` `<div>`, and both bit only under CI load. While the input machine is adjudicating or submitting a send — and in every locked state — the composer renders read-only by flipping `contenteditable` to `"false"` on the same element. On that element `fill()` throws immediately (`Element is not an <input>, <textarea> or [contenteditable] element`) instead of waiting through actionability, and `expect.poll(() => input.isEnabled())` is a no-op guard: Playwright's enablement check ignores both `aria-disabled` and `contenteditable` on a `<div>`, so it reports `true` throughout the read-only window. The exposed race is only a few frames wide — the permission-policy scenario stayed green for weeks until a Remote-routed subagent refactor stretched submit settling enough for CI to land inside it.
|
||||
|
||||
## Decision
|
||||
|
||||
Composer e2e gestures go through `writeComposerDraft` in `apps/web/tests/support.ts`, which waits for the editable attribute on the gesture's own target (`input.and(page.locator('[contenteditable="true"]'))`) before acting and replaces the draft with per-key strokes. Scenario code that must wait for the composer to reopen after a submit gates on the `contenteditable` attribute, never on `isEnabled()`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Waiting inside each scenario** instead of inside the helper: rejected — every new scenario re-discovers the trap the hard way, and the fix that motivated this note was already the second such site.
|
||||
- **Keeping `fill()` and polling `aria-disabled`** before each call: rejected — it leaves the dropped-edit race `fill()` has directly after trigger-menu and chip interactions (Lexical's internal selection lags the DOM selection inside one task), which the per-key helper also covers.
|
||||
- **Making the product surface tolerate `fill()`** (accepting synthetic edits while read-only): rejected — the read-only window is deliberate UI truth during submit adjudication; loosening it for tests would change user-visible behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A bare `input.fill(...)` against `[data-composer-input]` is a latent CI race even when it passes locally; the helper is the supported gesture.
|
||||
- `isEnabled()` on the composer asserts nothing. Existing polls of it guard nothing and read as coverage they do not provide.
|
||||
- A running turn by itself keeps the composer editable — that is what queueing types into — so the gate waits only through submit adjudication and locked states, not for turn completion.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Composer e2e 手势以 contenteditable 属性为门
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-26-composer-gesture-editable-gate.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
composer 变为 Lexical `contenteditable` `<div>` 后,两个 Playwright 手势语义悄然改变,且都只在 CI 高负载下咬人。输入机在裁决或发送一次提交期间——以及所有 locked 状态下——composer 通过把同一元素的 `contenteditable` 翻成 `"false"` 呈现只读。在该元素上 `fill()` 立即抛错(`Element is not an <input>, <textarea> or [contenteditable] element`)而不再经 actionability 等待;`expect.poll(() => input.isEnabled())` 则是无效护栏:Playwright 的 enablement 检查对 `<div>` 同时无视 `aria-disabled` 与 `contenteditable`,整个只读窗口内一律报 `true`。暴露的竞态只有几帧宽——permission-policy 场景绿了数周,直到 subagent 控制 Remote 化把提交 settle 拉长,CI 才落进窗口。
|
||||
|
||||
## 决策
|
||||
|
||||
composer 的 e2e 手势统一走 `apps/web/tests/support.ts` 的 `writeComposerDraft`:动作前在手势自身的目标上等待可编辑属性(`input.and(page.locator('[contenteditable="true"]'))`),再以逐键击键替换草稿。场景代码若需等待提交后 composer 重新开放,一律以 `contenteditable` 属性为门,永不使用 `isEnabled()`。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **在各场景内各自等待**而不是收进 helper:否决——每个新场景都会以最痛的方式重新发现这个陷阱,而促成本 note 的修复本身已是第二个踩点。
|
||||
- **保留 `fill()`、每次调用前 poll `aria-disabled`**:否决——这仍留着 `fill()` 在触发菜单与 chip 交互之后的丢编辑竞态(单 task 内 Lexical 内部 selection 落后于 DOM selection),逐键 helper 同时覆盖了它。
|
||||
- **让产品表面容忍 `fill()`**(只读期间接受合成编辑):否决——只读窗口是提交裁决期间刻意的 UI 事实;为测试放松它会改变用户可见行为。
|
||||
|
||||
## 后果
|
||||
|
||||
- 对 `[data-composer-input]` 裸写 `input.fill(...)` 即使本地全绿也是潜伏的 CI 竞态;helper 是受支持的手势。
|
||||
- 对 composer 调用 `isEnabled()` 断言不了任何东西。既有的此类 poll 不护任何路径,却读起来像提供了覆盖。
|
||||
- turn 运行本身保持 composer 可编辑——排队输入正是打进这里——因此该门只等待提交裁决与 locked 状态,不等待 turn 完成。
|
||||
@@ -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/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md
|
||||
2026-08-26-pi-ai-grant-payload-json-image.md: 9eb5e5ffd9ccbc18d409bd50e05061006bdca094
|
||||
2026-08-26-pi-ai-grant-payload-json-image.zh.md: 8329b4acf062a073dfe78ec10fe03852e7a64fd9
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: pi-ai grant payloads store their JSON image
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-26-pi-ai-grant-payload-json-image.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A GitHub Copilot sign-in against github.com failed at its commit step: `credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent`. pi-ai's Copilot credential carries its optional members as explicit `undefined` (`enterpriseUrl: undefined` when no Enterprise domain was given — idiomatic JavaScript that `JSON.stringify` would simply drop), and `llm-pi-ai`'s store bridge committed the credential object verbatim as the grant payload. The credential store's validator rightly refuses `undefined` as unrepresentable, so every grant whose flow left an optional member unset failed to store, and the sign-in reported failure after the provider had already authorized it.
|
||||
|
||||
## Decision
|
||||
|
||||
`toRecord` in `packages/llm/llm-pi-ai/src/auth.ts` stores the JSON image of a grant credential: `jsonImage` drops explicitly-undefined members of plain objects and renders undefined array entries as `null`, exactly as `JSON.stringify` would. Everything else — non-finite numbers, foreign-prototype objects — passes through untouched, so a genuinely unstorable value still fails loud at the store's validator rather than being silently reshaped. Reading back is unchanged: an absent member and an explicitly-undefined one are indistinguishable to pi-ai's consumers, which access optional members by property read.
|
||||
|
||||
## Testing
|
||||
|
||||
`tests/auth.spec.ts` writes the Copilot-shaped grant (explicit `undefined` member, nested drop, array hole) through the real `LocalCredentialProvider` and asserts the stored payload is the JSON image; a second case proves the fail-loud path survives by committing a `Date`-valued member and asserting the store's refusal reaches the caller.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**`JSON.parse(JSON.stringify(credential))`.** Rejected: it also renders `NaN`/`Infinity` as `null` and runs `toJSON` methods, silently reshaping exactly the values the strict validator exists to refuse loudly.
|
||||
|
||||
**Relaxing the store validator to skip `undefined` members.** Rejected: the seam stores payloads it never reads or reshapes, and every producer relies on byte-faithful round-trips; normalization belongs to the producer that knows its library's idiom, not to the store every plugin shares.
|
||||
|
||||
**Fixing pi-ai upstream to omit unset members.** Out of this repository's hands and version-fragile: any future flow reintroducing the idiom would break sign-in again. The bridge owning the translation makes the harness robust against the whole class.
|
||||
|
||||
## Consequences
|
||||
|
||||
Grants from every pi-ai flow store regardless of which optional members the flow left unset. The bridge now owns a one-way normalization: a payload read back lacks members that were explicitly `undefined` at write time, which is indistinguishable from their absence for property access, and remains the documented JSON semantics.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: pi-ai grant payload 落盘其 JSON 像
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-26-pi-ai-grant-payload-json-image.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
一次面向 github.com 的 GitHub Copilot 登录在提交环节失败:`credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent`。pi-ai 的 Copilot 凭据以显式 `undefined` 携带可选成员(未填 Enterprise 域名时为 `enterpriseUrl: undefined`——这是 `JSON.stringify` 会直接丢弃的 JavaScript 惯用写法),而 `llm-pi-ai` 的存储桥接把凭据对象原样作为 grant payload 提交。凭据存储的校验器正当地拒绝 `undefined` 为不可表示,于是所有流程留有未填可选成员的 grant 都无法落盘,提供方已经完成授权之后登录却报失败。
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/llm/llm-pi-ai/src/auth.ts` 的 `toRecord` 改为落盘 grant 凭据的 JSON 像:`jsonImage` 丢弃普通对象里显式为 undefined 的成员,把数组中的 undefined 条目渲染为 `null`,与 `JSON.stringify` 完全一致。其余一切——非有限数、异种原型对象——原样透传,因此真正不可存储的值仍会在存储校验器处大声失败,而不是被静默改写。读回不变:成员缺失与显式 undefined 对以属性读取访问可选成员的 pi-ai 消费方不可区分。
|
||||
|
||||
## Testing
|
||||
|
||||
`tests/auth.spec.ts` 经真实 `LocalCredentialProvider` 写入 Copilot 形状的 grant(显式 `undefined` 成员、嵌套丢弃、数组空洞),断言落盘 payload 为 JSON 像;第二个用例提交带 `Date` 成员的 grant 并断言存储的拒绝到达调用方,证明 fail-loud 路径仍在。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**`JSON.parse(JSON.stringify(credential))`。**否决:它还会把 `NaN`/`Infinity` 渲染为 `null` 并执行 `toJSON` 方法,把严格校验器本要大声拒绝的值静默改写掉。
|
||||
|
||||
**放宽存储校验器、跳过 undefined 成员。**否决:seam 存储的 payload 它从不读取或改写,所有生产方都依赖逐字节往返;归一化属于了解自家库惯用法的生产方,而不是所有插件共享的存储。
|
||||
|
||||
**在 pi-ai 上游修掉未填成员。**不在本仓库掌控内且随版本脆弱:将来任何流程重新引入该惯用法都会再次弄坏登录。由桥接层拥有这次翻译,让 harness 对整类问题免疫。
|
||||
|
||||
## Consequences
|
||||
|
||||
所有 pi-ai 流程的 grant 无论留空哪些可选成员都能落盘。桥接层现在拥有一次单向归一化:写入时显式为 undefined 的成员在读回时缺失,这对属性访问不可区分,且正是文档化的 JSON 语义。
|
||||
+6
@@ -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/bug-fix/2026-08-26-question-drafts-survive-session-switch.md
|
||||
2026-08-26-question-drafts-survive-session-switch.md: 2279c4efd5e79c51a0e2e2347f21fae4b0a47b7a
|
||||
2026-08-26-question-drafts-survive-session-switch.zh.md: af275a704bef723cb3ed6fde83138a2be5b5cb3f
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Question drafts survive Session switches
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-26-question-drafts-survive-session-switch.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`conversation.composer` is a strict Session-scoped slot, so selecting another Session unmounts its question entry. The generic `QuestionFlow` kept its current question index, selected labels, custom text, and skip flags in React component state. A still-pending request therefore returned with empty answers after an A → B → A Session switch even though the pending carrier remained owned by Session A.
|
||||
|
||||
The draft is transient presentation state: it must follow its Session within the current page, but it must not become mutable state on the pending business carrier or a user preference synchronized through Host settings.
|
||||
|
||||
## Decision
|
||||
|
||||
The question entry declares a non-persisted `createQuestionDraftStore` handle when it registers into `conversation.composer`. The renderer owns one instance per Session scope and retains that instance across selection changes, so remounting the same Session reads the same progress.
|
||||
|
||||
The store holds at most one request identity and one progress value: current question index plus one selected/custom/skipped draft per question. `QuestionFlow` reads the stored value only when the local pending-request key and question count match. A new request therefore renders empty immediately and its first write atomically replaces the older value instead of accumulating request records. Successful answer and cancellation settlements clear only their matching request key, so a stale completion cannot erase a later draft.
|
||||
|
||||
Busy state, failure feedback, collapse state, and focus bookkeeping remain component-local because they describe the mounted interaction rather than the unfinished answer. The `plan-review` presentation has no multi-question draft and does not read the store.
|
||||
|
||||
This realizes the existing [Session-scope rule](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) that remount-surviving state belongs in a Session-bound source, while retaining the [Host-backed preference decision](2026-08-06-host-backed-web-preferences.md): drafts remain page-local and never enter settings, `localStorage`, or disk. The answer semantics from [multi-select custom composition](2026-07-30-multi-select-custom-answer-composition.md) are unchanged.
|
||||
|
||||
## Testing
|
||||
|
||||
The store test pins keyed replacement and stale-cleanup isolation. The component test unmounts and remounts the strict entry over one store instance and requires its page, selected option, and custom text to return. The keyless assembled Web scenario types both answer forms, switches to a new Session, returns to the waiting Session, snapshots the restored composer, and submits the restored values through the real question waterfall.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the state in `QuestionFlow`.** Rejected because a strict Session switch deliberately destroys that React instance; a component-local key cannot outlive the unmount it is intended to identify.
|
||||
|
||||
**Put mutable drafts on `PendingQuestion`.** Rejected because the carrier represents pending request settlement, not React presentation state, and mutations there would bypass the Slot store's subscribed read/write surface and lifecycle ownership.
|
||||
|
||||
**Use a module-level map keyed by Session and request.** Rejected because plugin reload and Session pruning would not own its disposal, and completed request entries could accumulate independently of the renderer's scope lifecycle.
|
||||
|
||||
**Persist drafts through Host settings or browser storage.** Rejected because switching Sessions within one page needs remount continuity, not cross-page or cross-process durability. Persistence would synchronize transient answer text beyond the interaction that owns it.
|
||||
|
||||
## Consequences
|
||||
|
||||
Unsubmitted generic-question answers survive ordinary Session navigation in the current page, including the current question and explicit skips. They still reset after a page reload, Session-scope prune, or replacement pending-request identity. The per-Session memory cost is bounded to one request progress value and is released with the Slot store's Session scope.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 提问草稿在 Session 切换后保留
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-26-question-drafts-survive-session-switch.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`conversation.composer` 是严格按 Session 划分 scope 的 slot,因此选择另一个 Session 会卸载其提问条目。通用 `QuestionFlow` 把当前题号、已选标签、自定义文本和跳过标记保存在 React 组件状态中。因此,即使待处理载体仍归 Session A 所有,一个仍在等待的请求经过 A → B → A 的 Session 切换后,也会以空答案重新出现。
|
||||
|
||||
草稿是临时呈现状态:它必须在当前页面内跟随所属 Session,但不能变成待处理业务载体上的可变状态,也不能成为通过 Host settings 同步的用户偏好。
|
||||
|
||||
## Decision
|
||||
|
||||
提问条目注册到 `conversation.composer` 时声明一个非持久化的 `createQuestionDraftStore` handle。renderer 为每个 Session scope 拥有一个实例,并在选择切换期间保留该实例,因此重新挂载同一 Session 时会读到相同进度。
|
||||
|
||||
store 最多保存一个请求标识和一个进度值:当前题号,以及每道题各一份 selected/custom/skipped 草稿。只有本地待处理请求 key 与题目数量都相符时,`QuestionFlow` 才读取已存值。因此,新请求会立即渲染为空,并在首次写入时原子替换旧值,而不会累积请求记录。成功回答和取消落定后只清除与自身相符的请求 key,因此过期的完成动作不会删除较新的草稿。
|
||||
|
||||
忙碌状态、失败提示、折叠状态和焦点记录仍留在组件本地,因为它们描述当前已挂载交互,而不是未完成的答案。`plan-review` 呈现界面没有多题草稿,也不读取该 store。
|
||||
|
||||
这落实了既有的 [Session scope 规则](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md):需要跨重新挂载保留的状态应归 Session 绑定的数据源;同时保留[由 Host 持久化偏好的决策](2026-08-06-host-backed-web-preferences.zh.md):草稿仍只存在于当前页面,从不进入 settings、`localStorage` 或磁盘。[多选自定义答案组合](2026-07-30-multi-select-custom-answer-composition.zh.md)规定的答案语义保持不变。
|
||||
|
||||
## Testing
|
||||
|
||||
store 测试固定按 key 替换和过期清理隔离。组件测试在同一个 store 实例上卸载并重新挂载严格 Session 条目,并要求题号、已选选项和自定义文本全部恢复。无密钥的组装 Web 场景会输入两种答案、切换到新 Session、返回仍在等待的 Session、对恢复后的编辑器生成快照,再经真实提问 waterfall 提交恢复的值。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**继续把状态留在 `QuestionFlow`。** 不采用,因为严格 Session 切换会刻意销毁该 React 实例;组件本地 key 无法比其试图标识的卸载过程活得更久。
|
||||
|
||||
**把可变草稿放进 `PendingQuestion`。** 不采用,因为载体表示待处理请求的落定过程,而不是 React 呈现状态;在其中做变更还会绕过 Slot store 提供的订阅读写界面和生命周期归属。
|
||||
|
||||
**使用按 Session 和请求建立索引的模块级 map。** 不采用,因为 plugin 重载与 Session 裁剪不拥有其清理过程,已完成请求的条目还可能脱离 renderer 的 scope 生命周期不断累积。
|
||||
|
||||
**通过 Host settings 或浏览器存储持久化草稿。** 不采用,因为同一页面内切换 Session 需要的是跨重新挂载连续性,而不是跨页面或跨进程耐久性。持久化会把临时答案文本同步到拥有它的交互之外。
|
||||
|
||||
## Consequences
|
||||
|
||||
未提交的通用提问答案现在能在当前页面的普通 Session 导航中保留,包括当前题号和显式跳过状态。刷新页面、Session scope 被裁剪或待处理请求标识被替换后,草稿仍会重置。每个 Session 的内存成本被限制为一个请求进度值,并随 Slot store 的 Session scope 一起释放。
|
||||
+2
-2
@@ -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-08-08-native-windows-pull-request-ci.md
|
||||
2026-08-08-native-windows-pull-request-ci.md: 98f48029a86a8b07e53cd4498b27d637508e450b
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: b2dba91e4a88d0da637521aae2de937672b869a3
|
||||
2026-08-08-native-windows-pull-request-ci.md: 8faec10fdc8538c994954213ac477a2ed52e800c
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: f692aa85e33e2e02d04cd72d9316ed4fc139aced
|
||||
|
||||
@@ -14,17 +14,17 @@ A coverage audit found that stale branch state had restored temporary exclusions
|
||||
|
||||
The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
|
||||
|
||||
Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup` standalone mode, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.
|
||||
Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable retargets all four jobs to the in-house pool. Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
|
||||
|
||||
The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict.
|
||||
`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts.
|
||||
|
||||
The 16-core lane admits four concurrent outer gates. Workspace build and production-site validation start immediately. Instrumented and exempt-heavy coverage both wait for the complete build: the instrumented corpus includes packer assertions over built `lib/` output, while the exempt gate's temporary Oxlint contract probes must not race source compilation and its packed-image suite must read a complete artifact tree. Every observational gate waits for both coverage gates to settle, regardless of outcome, before entering an available slot; its own `needs` edges still require their predecessors to pass. This also keeps later static gates that create temporary contract files from racing either coverage scan. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses four single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`, for about six active coverage execution units after build. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The SQLite busy-journal pacing fixture injects two busy results followed by success under the normal busy budget and observes each inter-attempt delay, keeping schema-setup scheduling outside its timing assertion. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
|
||||
`windows-coverage` completes a workspace build before [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) starts four single-worker instrumented shards beside a two-worker exempt-heavy gate. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, Knip, NodeNext, package, and documentation processes. The SQLite busy-journal pacing fixture injects two busy results followed by success under the normal busy budget and observes each inter-attempt delay, keeping schema-setup scheduling outside its timing assertion. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
|
||||
|
||||
The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. Under the current post-build graph, sixteen instrumented shards plus two exempt workers would schedule eighteen coverage execution units on a 16-core runner before any production-site tail or system overhead; four shards plus two exempt workers schedule six. Four deliberately trades some single-job latency for lower process-creation pressure under high self-hosted concurrency. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
|
||||
The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers after the build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
|
||||
|
||||
The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias.
|
||||
|
||||
Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling `process.exit()` inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling.
|
||||
Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling `process.exit()` inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. The randomized SQLite differential property retains all 100 seeded runs and uses a 120-second Windows budget because simultaneous native jobs can contend for the shared runner host; POSIX keeps the 60-second budget.
|
||||
|
||||
Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary.
|
||||
|
||||
@@ -36,11 +36,11 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the slowest hosted job and for Windows capacity. The independent result keeps the signal automatic without changing the existing required path.
|
||||
**Make every native Windows result a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for coverage and the duplicated observational inventory. Requiring the build and targeted native-process suite retains fast native correctness signals while the other results remain automatic.
|
||||
|
||||
**Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken.
|
||||
|
||||
**Mark the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism.
|
||||
**Mark every non-blocking native job `continue-on-error`.** The observational job uses this setting because Linux owns its blocking verdict. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate.
|
||||
|
||||
**Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures.
|
||||
|
||||
@@ -50,8 +50,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
|
||||
|
||||
## Consequences
|
||||
|
||||
Wine preserves the required aggregate's existing critical path and job identity. Native Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result.
|
||||
Wine preserves the required aggregate's existing critical path and job identity. Native coverage and observational results can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine plus the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results.
|
||||
|
||||
Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native job duplicates setup and the two blocking builds and is materially slower on the standard image, but it also exposes path, watcher, lifecycle, and fixture defects hidden by the compatibility lane.
|
||||
Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup and repeat builds across the build, coverage, and observational workspaces, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane.
|
||||
|
||||
Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native job uses the immutable workspace on the organization-owned 16-core Windows runner. A failure unique to either job must be classified against that boundary rather than weakened or silently skipped.
|
||||
Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native jobs use separate immutable workspaces on the organization-owned 16-core Windows runner. A failure unique to either topology must be classified against that boundary rather than weakened or silently skipped.
|
||||
|
||||
@@ -14,17 +14,17 @@ Status: implemented
|
||||
|
||||
[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
|
||||
|
||||
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 的 standalone 模式提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。
|
||||
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量会把这 4 个作业全部重定向到公司内部运行器池。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
|
||||
|
||||
原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。
|
||||
`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。
|
||||
|
||||
16 核通道最多同时运行 4 道外层门禁。工作区构建与生产网站验证会立即启动。插桩覆盖率与豁免重型覆盖率都等待完整构建:插桩语料包含针对已构建 `lib/` 输出的打包器断言,豁免门禁的临时 Oxlint 约定探针则不得与源码编译竞态,并且其 packed-image 套件必须读取完整的产物树。每道观测性门禁只等待两道覆盖率门禁以任意结果结算后再进入可用槽位;各门禁自身的 `needs` 边仍要求前置门禁通过。这也使随后创建临时约定文件的静态门禁不会与任一覆盖率扫描竞态。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)使用 4 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker,因此构建完成后约有 6 个活动覆盖率执行单元。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。SQLite busy-journal 节奏 fixture 会在普通 busy 预算内先注入两次 busy 结果,再返回成功,并观察每次尝试之间的延迟,使 schema 设置的调度时间不进入该断言。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
|
||||
`windows-coverage` 会先完成一次工作区构建,再由[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)启动 4 个单 worker 插桩分片,并与一个双 worker 的豁免重型门禁并行运行。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、Knip、NodeNext、包及文档进程争抢资源。SQLite busy-journal 节奏 fixture 会在普通 busy 预算内先注入两次 busy 结果,再返回成功,并观察每次尝试之间的延迟,使 schema 设置的调度时间不进入该断言。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
|
||||
|
||||
16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。在当前的构建后拓扑中,16 个插桩分片加 2 个豁免 worker 会在 16 核运行器上调度 18 个覆盖率执行单元,且尚未计入生产网站的尾部工作或系统开销;4 个分片加 2 个豁免 worker 则调度 6 个。4 个分片刻意用部分单 job 延迟换取自托管高并发下更低的进程创建压力。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
|
||||
16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在构建后调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
|
||||
|
||||
首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。
|
||||
|
||||
可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。worker 死亡 fixture 会先观察其协议前置条件,再由宿主触发真实终止,而不在嵌套 Windows Worker 中调用 `process.exit()`;这样既保留了 worker 退出约定,也不会让外围 Vitest fork 暴露于 Node 进程级的原生退出断言。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。
|
||||
可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。worker 死亡 fixture 会先观察其协议前置条件,再由宿主触发真实终止,而不在嵌套 Windows Worker 中调用 `process.exit()`;这样既保留了 worker 退出约定,也不会让外围 Vitest fork 暴露于 Node 进程级的原生退出断言。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。SQLite 随机差分属性测试保留全部 100 次固定 seed 运行,并采用 120 秒 Windows 预算,因为多个原生作业可能争用共享的运行器宿主;POSIX 仍采用 60 秒预算。
|
||||
|
||||
原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识、挂接监听器并在插件启动完成前等待主 watcher 就绪,因此启动后立即发生的编辑不会与初始扫描形成竞态。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。
|
||||
|
||||
@@ -36,11 +36,11 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最慢的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径。
|
||||
**让每项原生 Windows 结果都成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待覆盖率和重复的观测性清单。要求构建与定向原生进程套件通过,可以保留快速的原生正确性信号,同时继续自动产生其他结果。
|
||||
|
||||
**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。
|
||||
|
||||
**将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留常规独立作业可维持诊断结论;仅从聚合流程的 `needs` 中省略它,才是不阻断的机制。
|
||||
**将每个非阻断原生作业都标记为 `continue-on-error`。** 观测性作业采用该设置,因为它的阻断性判定由 Linux 负责。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。
|
||||
|
||||
**排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注;可移植结果继续计入分母,并通过符合真实宿主行为的 fixture 验证。
|
||||
|
||||
@@ -50,8 +50,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
|
||||
|
||||
## 后果
|
||||
|
||||
Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生 Windows 仍可能处于待处理或红灯状态,因此分支保护采用 Wine 结果,而评审者和后续自动化采用独立的原生结果。
|
||||
Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生覆盖率与观测性结果仍可能处于待处理或红灯状态,因此分支保护采用 Wine 加定向原生构建和进程检查,而评审者和后续自动化采用其余原生结果。
|
||||
|
||||
尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程与两项阻断构建,在标准镜像上明显更慢;但它也会暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。
|
||||
尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程,并在构建、覆盖率与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。
|
||||
|
||||
维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用不可变工作区。任一作业独有的失败都必须依据该边界分类,不得削弱或静默跳过。
|
||||
维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用相互独立的不可变工作区。任一拓扑独有的失败都必须依据该边界分类,不得削弱或静默跳过。
|
||||
|
||||
@@ -4,9 +4,10 @@ name: Build single-exe
|
||||
# .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
|
||||
# A full target run retains one SDK wheel and four runtime wheels; subset
|
||||
# dispatch retains the SDK wheel and selected runtime wheels. Bare executables
|
||||
# and source closures are test inputs. Run manually, label a PR `build-exe`
|
||||
# (remove and reapply to rerun), or call it from the Python release workflow.
|
||||
# Checkout uses the triggering ref, so dispatch needs no separate ref input.
|
||||
# and source closures are test inputs. Run manually or call it from the Python
|
||||
# release workflow. There is no `pull_request` trigger: a label trigger would
|
||||
# list gray skipped checks on every unrelated PR label event. Checkout uses the
|
||||
# triggering ref, so dispatch needs no separate ref input.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
@@ -39,8 +40,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
concurrency:
|
||||
# Keep the called workflow distinct from its caller's concurrency group;
|
||||
@@ -62,7 +61,7 @@ jobs:
|
||||
# construct the matrix before the dependent jobs.
|
||||
plan:
|
||||
name: plan targets
|
||||
if: inputs.ci || inputs.release || github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe'
|
||||
if: inputs.ci || inputs.release || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -89,7 +88,7 @@ jobs:
|
||||
- name: Compute matrix from targets input
|
||||
id: plan
|
||||
env:
|
||||
# Label runs and blank dispatch inputs build all targets.
|
||||
# Blank dispatch inputs build all targets.
|
||||
TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -452,7 +452,7 @@ jobs:
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: '6'
|
||||
DSH_COVERAGE_PARTITIONS: '6'
|
||||
DSH_COVERAGE_PARTITIONS: '4'
|
||||
DSH_COVERAGE_TEST_TIMEOUT_MS: '30000'
|
||||
DSH_GATE_CONCURRENCY: '3'
|
||||
steps:
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
assertFinalWorkspaceSnapshot, assertFixtureInventory, fixtureUserPrompts, launchWebScaffold, recordFixture,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context/session.jsonl', import.meta.url))
|
||||
@@ -94,26 +94,26 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS)
|
||||
}
|
||||
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
|
||||
for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) {
|
||||
await input.fill(`/permission ${preset}`)
|
||||
await writeComposerDraft(page, input, `/permission ${preset}`)
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: `Access mode, current: ${PRESET_LABELS[index]}` })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPTS[index] as string)
|
||||
await writeComposerDraft(page, input, PROMPTS[index] as string)
|
||||
await input.press('Enter')
|
||||
sessionId = await settled
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
await input.fill('/permission read-only')
|
||||
await writeComposerDraft(page, input, '/permission read-only')
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPTS[3])
|
||||
await writeComposerDraft(page, input, PROMPTS[3])
|
||||
await input.press('Enter')
|
||||
sessionId = await settled
|
||||
|
||||
|
||||
@@ -195,6 +195,21 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await custom.inputValue()).toBe('Include accessibility notes')
|
||||
if (MODE !== 'record') {
|
||||
// A strict Session-slot switch remounts the composer. Open a fresh blank
|
||||
// Session, then return to the still-waiting request and require its
|
||||
// Session-scoped store to restore both option and free-text drafts.
|
||||
const originalRow = page.locator('[role="treeitem"]')
|
||||
.filter({ hasText: 'Use the ask_user_question tool' }).first()
|
||||
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
|
||||
await page.getByText('New Session', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => composer.count(), { timeout: 10_000 }).toBe(0)
|
||||
await originalRow.click()
|
||||
await composer.waitFor({ timeout: 15_000 })
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await custom.inputValue()).toBe('Include accessibility notes')
|
||||
|
||||
// This golden now owns the composed state after a real A -> B -> A
|
||||
// Session cycle, not merely the state before the remount.
|
||||
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
|
||||
}
|
||||
|
||||
@@ -117,6 +117,15 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = '
|
||||
* selection, and the batched edit lands on a null selection and is silently
|
||||
* dropped, leaving the previous draft in place. Real keystrokes leave room for
|
||||
* `selectionchange` between keys, which is also what a user's typing does.
|
||||
*
|
||||
* Waits for the surface to be editable first. While the input machine is
|
||||
* adjudicating or submitting a send — and in every locked state (removed
|
||||
* session, no workspace, an owner block) — the composer renders read-only
|
||||
* with `contenteditable="false"` on the same element. `fill()` throws
|
||||
* immediately on that element, and `isEnabled()` reports `true` for a
|
||||
* `<div>` regardless of the attribute — so a gesture directly after a
|
||||
* submit must gate on the attribute, not on enablement. A running turn by
|
||||
* itself keeps the composer editable (that is what queueing types into).
|
||||
* @param page - the page under test.
|
||||
* @param input - the `[data-composer-input]` surface locator.
|
||||
* @param text - the replacement draft; `''` clears the draft. Must not
|
||||
@@ -127,6 +136,7 @@ export async function writeComposerDraft(
|
||||
input: ReturnType<Page['locator']>,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
await input.and(page.locator('[contenteditable="true"]')).waitFor({ timeout: 15_000 })
|
||||
await input.click()
|
||||
await page.keyboard.press('ControlOrMeta+A')
|
||||
if (text === '') await page.keyboard.press('Backspace')
|
||||
|
||||
@@ -6,12 +6,24 @@
|
||||
// selecting the ledger record renders the shared ui-attachment gallery from
|
||||
// the durable session-log reference, and the browser URL is the SAME object
|
||||
// URL Chat resolved — one sessions.attachment read per session attachment.
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
|
||||
|
||||
installAssembledBootEnv()
|
||||
|
||||
/**
|
||||
* How long the mounted tree waits out the virtual ledger's scroll-idle timer.
|
||||
* jsdom fires no `scrollend`, so `@tanstack/react-virtual` falls back to a
|
||||
* debounce it re-arms on every scroll event (`isScrollingResetDelay`, 150ms by
|
||||
* default) and its unsubscribe removes only the listeners; a scenario that
|
||||
* ends inside that window leaves the timer to re-render the table after vitest
|
||||
* has torn this file's jsdom down, where React reads a `window` that is gone.
|
||||
* Armed later and with a longer delay than the debounce, this wait always
|
||||
* expires after it.
|
||||
*/
|
||||
const SCROLL_IDLE_DRAIN_MS = 400
|
||||
|
||||
/** Open the fixture history session and wait for the Chat gallery to load. */
|
||||
async function openFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
@@ -41,16 +53,24 @@ async function scrollRowIntoWindow(needle: string): Promise<HTMLElement> {
|
||||
}, { timeout: 10_000 })
|
||||
const pane = document.querySelector('[data-trajectory-scroll] table')?.parentElement
|
||||
if (!(pane instanceof HTMLElement)) throw new Error('trajectory scroll pane missing')
|
||||
for (let top = 0; top <= 40_000; top += 1_000) {
|
||||
const findRow = (): HTMLElement | undefined =>
|
||||
[...document.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
.find(row => row.textContent?.includes(needle))
|
||||
let mounted = false
|
||||
for (let top = 0; !mounted && top <= 40_000; top += 1_000) {
|
||||
pane.scrollTop = top
|
||||
fireEvent.scroll(pane)
|
||||
// Let the virtualizer publish the new window before probing.
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
const hit = [...document.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
.find(row => row.textContent?.includes(needle))
|
||||
if (hit !== undefined) return hit
|
||||
mounted = findRow() !== undefined
|
||||
}
|
||||
throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`)
|
||||
// Nothing scrolls the ledger after this, so draining the scroll-idle
|
||||
// debounce here leaves no timer armed for the rest of the scenario. The
|
||||
// drained reset re-renders the window, so the row is read afterwards.
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, SCROLL_IDLE_DRAIN_MS)) })
|
||||
const hit = findRow()
|
||||
if (hit === undefined) throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`)
|
||||
return hit
|
||||
}
|
||||
|
||||
it('renders durable record images in the Trajectory details panel from the shared cache', async () => {
|
||||
|
||||
@@ -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 docs/config-catalog.md
|
||||
config-catalog.md: d6a09507d90437951b8cdcbcdaf7757cf267600f
|
||||
config-catalog.zh.md: 1caa38f1997148fbb4d7457f8ff326376bb88c57
|
||||
config-catalog.md: c3471c8098ba1eabfef82c46354a5d858637963a
|
||||
config-catalog.zh.md: 89433a38b90cf75b9a6fa53929920ca39dcbfdc4
|
||||
|
||||
@@ -1309,7 +1309,7 @@ export interface ReplayModelConfig {
|
||||
|
||||
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
|
||||
|
||||
Source: [`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-support/llm-replay/src/index.ts)
|
||||
Source: [`packages/test-support/llm-replay/src/index.ts:918`](../packages/test-support/llm-replay/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-llm-retry"></a>
|
||||
|
||||
|
||||
@@ -1311,7 +1311,7 @@ export interface ReplayModelConfig {
|
||||
|
||||
依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
|
||||
|
||||
来源:[`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-support/llm-replay/src/index.ts)
|
||||
来源:[`packages/test-support/llm-replay/src/index.ts:918`](../packages/test-support/llm-replay/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-llm-retry"></a>
|
||||
|
||||
|
||||
@@ -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 docs/subsystems/client-modules.md
|
||||
client-modules.md: e80329be63c957407df5c9e06fd94780b66459bc
|
||||
client-modules.zh.md: c42f04f3d8be53aa5e5d7cb3c57d06ccd5e21e72
|
||||
client-modules.md: 6188880fcd682c7c3c105764f212e92395444b61
|
||||
client-modules.zh.md: a732758492e1dbc9cbaef8d3effbd3310e4f10a1
|
||||
|
||||
@@ -95,16 +95,12 @@ interface ClientArtifactBaseline {
|
||||
readonly mtimeMs: number
|
||||
/** Bundle size in bytes. */
|
||||
readonly size: number
|
||||
/** Source-map modification time, or null when no map was observable. */
|
||||
readonly mapMtimeMs: number | null
|
||||
/** Source-map size in bytes, or null when no map was observable. */
|
||||
readonly mapSize: number | null
|
||||
}
|
||||
```
|
||||
|
||||
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle/map stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes that artifact, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
|
||||
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle together with its current source map, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
|
||||
|
||||
In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle and optional map from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Production graphs omit the HMR row entirely; the module host itself never watches files.
|
||||
In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Source-map changes alone do not trigger a reload; the current map joins the snapshot when a bundle change does. Production graphs omit the HMR row entirely; the module host itself never watches files.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -95,16 +95,12 @@ interface ClientArtifactBaseline {
|
||||
readonly mtimeMs: number
|
||||
/** Bundle size in bytes. */
|
||||
readonly size: number
|
||||
/** Source-map modification time, or null when no map was observable. */
|
||||
readonly mapMtimeMs: number | null
|
||||
/** Source-map size in bytes, or null when no map was observable. */
|
||||
readonly mapSize: number | null
|
||||
}
|
||||
```
|
||||
|
||||
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle/map stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它只对该产物重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
|
||||
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它把 bundle 与当前 source map 一起重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
|
||||
|
||||
开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 与可选 map 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。
|
||||
开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。仅 source map 变化不会触发重载;bundle 变化时,当前 map 会一起进入快照。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -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 docs/subsystems/persistence.md
|
||||
persistence.md: 402836cb727fb99d92cea5e2a0d242b010aad2c9
|
||||
persistence.zh.md: 424fc928d5a8ef18b403ea31e5a5d26d3fd3fdc7
|
||||
persistence.md: f73b9ab01c232c4d4fec5aa51e5250c60b9337da
|
||||
persistence.zh.md: 061c29f6b54c41137e3c764e9a7804f411f63f17
|
||||
|
||||
@@ -233,7 +233,7 @@ interface SessionPersistenceSnapshot {
|
||||
All implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite:
|
||||
|
||||
- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path.
|
||||
- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 18 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them.
|
||||
- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 19 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ interface SessionPersistenceSnapshot {
|
||||
两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件:
|
||||
|
||||
- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——逐会话仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。
|
||||
- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 18 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。
|
||||
- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 19 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -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 docs/subsystems/slots.md
|
||||
slots.md: 6eb61780ca2f06ebc38a0fcf2638a7fafcd5ceee
|
||||
slots.zh.md: e5a25763d382c228525f3da24694e41dd09737e0
|
||||
slots.md: 8e115e30aed68e543eca2f1aac6e28ad9f57cf73
|
||||
slots.zh.md: 3894b69d8d020b4bb67ce325d389ab3f20cfcc9a
|
||||
|
||||
@@ -123,6 +123,8 @@ root
|
||||
│ ├─ settings.onboarding
|
||||
│ └─ settings.section
|
||||
│ ├─ settings.general.item
|
||||
│ ├─ settings.models.provider-card
|
||||
│ ├─ settings.models.footer
|
||||
│ └─ settings.plugins.tab
|
||||
│ └─ settings.plugin.item
|
||||
├─ conversation
|
||||
|
||||
@@ -123,6 +123,8 @@ root
|
||||
│ ├─ settings.onboarding
|
||||
│ └─ settings.section
|
||||
│ ├─ settings.general.item
|
||||
│ ├─ settings.models.provider-card
|
||||
│ ├─ settings.models.footer
|
||||
│ └─ settings.plugins.tab
|
||||
│ └─ settings.plugin.item
|
||||
├─ conversation
|
||||
|
||||
@@ -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/hmr/README.md
|
||||
README.md: 88438279301d4a893f2c245a6580e63ea0ca7930
|
||||
README.zh.md: f56abdbb0473a850af933be0653e217fd51668de
|
||||
README.md: de420ef2c809ccc13feea320ae3ffda3720f82a3
|
||||
README.zh.md: c7066e369b38fa3ffda6888831bba7780c5da8af
|
||||
|
||||
@@ -59,7 +59,7 @@ This section explains how the reload chain is built; observable behavior is cove
|
||||
|
||||
### Design concept
|
||||
|
||||
The chain is two halves with one contract: the node half owns bundle detection and notification, the browser half owns the swap. The node half runs one interval that stat-polls each graph bundle and optional source map from the module host's pre-read baseline. An unchanged startup row starts watching without a content read or hash; a changed row, or a dirty row whose artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. It also serves `/plugins/events`, an SSE channel broadcasting `graph` and `rebuilt` frames.
|
||||
The chain is two halves with one contract: the node half owns bundle detection and notification, the browser half owns the swap. The node half runs one interval that stat-polls each graph bundle from the module host's pre-read baseline. An unchanged startup row starts watching without a content read or hash; a changed row, or a dirty row whose artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. `rebuilt()` reads the current source map together with the changed bundle; a map-only write does not reload executable code. The node half also serves `/plugins/events`, an SSE channel broadcasting `graph` and `rebuilt` frames.
|
||||
|
||||
### The browser swap
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ kind: "package-reference"
|
||||
|
||||
### 设计理念
|
||||
|
||||
链路分为两半,共用一份约定:node 半侧负责 bundle 检测与通知,浏览器半侧负责替换。node 半侧运行一个 interval,从 module host 读取文件前的基线开始 stat 轮询每个图 bundle 及其可选 source map。未变化的启动 row 无需读取内容或求 hash 即可开始监视;发生变化的 row,或产物恢复后的 dirty row,会进入 `rebuilt()`,且只广播真实 revision 变更。它还提供 `/plugins/events`,一个广播 `graph` 与 `rebuilt` 帧的 SSE 通道。
|
||||
链路分为两半,共用一份约定:node 半侧负责 bundle 检测与通知,浏览器半侧负责替换。node 半侧运行一个 interval,从 module host 读取文件前的基线开始 stat 轮询每个图 bundle。未变化的启动 row 无需读取内容或求 hash 即可开始监视;发生变化的 row,或产物恢复后的 dirty row,会进入 `rebuilt()`,且只广播真实 revision 变更。`rebuilt()` 会把当前 source map 与已变化的 bundle 一起读取;仅写入 map 不会重载可执行代码。node 半侧还提供 `/plugins/events`,一个广播 `graph` 与 `rebuilt` 帧的 SSE 通道。
|
||||
|
||||
### 浏览器侧替换
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* HMR plugin, node half: the host end of the dev reload chain. One interval
|
||||
* stat-polls every graph row's client bundle and optional source map (polling
|
||||
* by design: network mounts deliver no inotify events), reports changes through
|
||||
* stat-polls every graph row's client bundle (polling by design: network mounts
|
||||
* deliver no inotify events), reports changes through
|
||||
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
|
||||
* broadcasting graph/rebuilt frames to the browser half (src/client/).
|
||||
* The web bundle mounts this row unconditionally: without a rebuild
|
||||
@@ -42,30 +42,22 @@ function sseData(frame: PluginsEventFrame): string {
|
||||
return `data: ${JSON.stringify(frame)}\n\n`
|
||||
}
|
||||
|
||||
type WatchedArtifactStat = Omit<ClientArtifactBaseline, 'path'>
|
||||
type WatchedBundleStat = Omit<ClientArtifactBaseline, 'path'>
|
||||
|
||||
type WatchedBundle = {
|
||||
-readonly [K in keyof ClientArtifactBaseline]: ClientArtifactBaseline[K]
|
||||
} & { dirty: boolean }
|
||||
|
||||
/** Snapshot the bundle plus its optional development source map. */
|
||||
function artifactStat(path: string): WatchedArtifactStat {
|
||||
/** Snapshot the executable bundle metadata that drives reloads. */
|
||||
function bundleStat(path: string): WatchedBundleStat {
|
||||
const bundle = statSync(path)
|
||||
try {
|
||||
const map = statSync(`${path}.map`)
|
||||
return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: map.mtimeMs, mapSize: map.size }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
|
||||
}
|
||||
return { mtimeMs: bundle.mtimeMs, size: bundle.size }
|
||||
}
|
||||
|
||||
/** Whether neither served artifact changed since the last successful re-hash. */
|
||||
function sameArtifactStat(left: WatchedArtifactStat, right: WatchedArtifactStat): boolean {
|
||||
/** Whether the executable bundle is unchanged since the last successful re-hash. */
|
||||
function sameBundleStat(left: WatchedBundleStat, right: WatchedBundleStat): boolean {
|
||||
return left.mtimeMs === right.mtimeMs
|
||||
&& left.size === right.size
|
||||
&& left.mapMtimeMs === right.mapMtimeMs
|
||||
&& left.mapSize === right.mapSize
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +72,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// --- bundle watch: one HMR-owned stat poll ------------------------------
|
||||
const watched = new Map<string, WatchedBundle>()
|
||||
|
||||
const rehash = (id: string, watch: WatchedBundle, current: WatchedArtifactStat): void => {
|
||||
const rehash = (id: string, watch: WatchedBundle, current: WatchedBundleStat): void => {
|
||||
try {
|
||||
// rebuilt() replaces the opaque startup rev on its first call; later
|
||||
// calls stay silent when the content hash is unchanged.
|
||||
@@ -95,17 +87,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
watch.mtimeMs = current.mtimeMs
|
||||
watch.size = current.size
|
||||
watch.mapMtimeMs = current.mapMtimeMs
|
||||
watch.mapSize = current.mapSize
|
||||
watch.dirty = false
|
||||
}
|
||||
|
||||
const watchRow = (id: string, baseline: ClientArtifactBaseline): void => {
|
||||
const watch: WatchedBundle = { ...baseline, dirty: false }
|
||||
watched.set(id, watch)
|
||||
let current: WatchedArtifactStat
|
||||
let current: WatchedBundleStat
|
||||
try {
|
||||
current = artifactStat(baseline.path)
|
||||
current = bundleStat(baseline.path)
|
||||
} catch (error) {
|
||||
watch.dirty = true
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
|
||||
@@ -113,20 +103,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
// The module host captured its baseline before reading the bytes in the
|
||||
// startup batch. Only a mismatch crosses into the content-hash path.
|
||||
if (!sameArtifactStat(current, watch)) rehash(id, watch, current)
|
||||
if (!sameBundleStat(current, watch)) rehash(id, watch, current)
|
||||
}
|
||||
|
||||
const pollWatches = (): void => {
|
||||
for (const [id, watch] of watched) {
|
||||
let current: WatchedArtifactStat
|
||||
let current: WatchedBundleStat
|
||||
try {
|
||||
current = artifactStat(watch.path)
|
||||
current = bundleStat(watch.path)
|
||||
} catch (error) {
|
||||
watch.dirty = true
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
|
||||
continue
|
||||
}
|
||||
if (!watch.dirty && sameArtifactStat(current, watch)) continue
|
||||
if (!watch.dirty && sameBundleStat(current, watch)) continue
|
||||
// Stat-before-hash preserves a detectable older baseline for writes that
|
||||
// land during hashing. Repeated stat changes heal a torn read.
|
||||
rehash(id, watch, current)
|
||||
|
||||
@@ -31,19 +31,7 @@ interface FakeHostOptions {
|
||||
|
||||
function artifactBaseline(path: string): ClientArtifactBaseline {
|
||||
const bundle = statSync(path)
|
||||
try {
|
||||
const sourceMap = statSync(`${path}.map`)
|
||||
return {
|
||||
path,
|
||||
mtimeMs: bundle.mtimeMs,
|
||||
size: bundle.size,
|
||||
mapMtimeMs: sourceMap.mtimeMs,
|
||||
mapSize: sourceMap.size,
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
return { path, mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
|
||||
}
|
||||
return { path, mtimeMs: bundle.mtimeMs, size: bundle.size }
|
||||
}
|
||||
|
||||
function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
|
||||
@@ -111,7 +99,7 @@ async function mount(clientModuleHost: FakeHost, webServer: WebServer) {
|
||||
}
|
||||
|
||||
describe('hmr node half', () => {
|
||||
it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
|
||||
it('watches graph bundles, ignores map-only changes, and unwatches on dispose', async () => {
|
||||
const bundle = join(dir, 'a.js')
|
||||
writeFileSync(bundle, 'v1')
|
||||
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
|
||||
@@ -130,13 +118,17 @@ describe('hmr node half', () => {
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
||||
writeFileSync(`${bundle}.map`, '{"version":3}')
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
|
||||
expect(clientModuleHost.rebuiltCalls).toEqual([])
|
||||
|
||||
writeFileSync(bundle, 'v3-even-longer')
|
||||
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
|
||||
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
// Watcher gone: further file changes report nothing.
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
writeFileSync(bundle, 'v3-even-longer')
|
||||
writeFileSync(bundle, 'v4-after-dispose')
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
|
||||
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { readFileSync, statSync, type Stats } from 'node:fs'
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -78,10 +78,6 @@ export interface ClientArtifactBaseline {
|
||||
readonly mtimeMs: number
|
||||
/** Bundle size in bytes. */
|
||||
readonly size: number
|
||||
/** Source-map modification time, or null when no map was observable. */
|
||||
readonly mapMtimeMs: number | null
|
||||
/** Source-map size in bytes, or null when no map was observable. */
|
||||
readonly mapSize: number | null
|
||||
}
|
||||
|
||||
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
|
||||
@@ -756,22 +752,13 @@ export class ClientModuleRegistry extends Service {
|
||||
return meta
|
||||
}
|
||||
|
||||
/** Capture the bundle and optional-map stats before reading their bytes. */
|
||||
/** Capture the bundle stats before reading its bytes. */
|
||||
private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
|
||||
const bundle = statSync(clientPath)
|
||||
let sourceMap: Stats | undefined
|
||||
try {
|
||||
sourceMap = statSync(`${clientPath}.map`)
|
||||
} catch {
|
||||
// Optional map metadata only seeds HMR; the following map read reports
|
||||
// malformed or inaccessible bytes and a later stat change self-heals.
|
||||
}
|
||||
return {
|
||||
path: clientPath,
|
||||
mtimeMs: bundle.mtimeMs,
|
||||
size: bundle.size,
|
||||
mapMtimeMs: sourceMap?.mtimeMs ?? null,
|
||||
mapSize: sourceMap?.size ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -378,8 +378,6 @@ describe('client bundle activation', () => {
|
||||
path: firstPath,
|
||||
mtimeMs: firstStat.mtimeMs,
|
||||
size: firstStat.size,
|
||||
mapMtimeMs: null,
|
||||
mapSize: null,
|
||||
})
|
||||
expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -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-settings-models/README.md
|
||||
README.md: c1d2e08a32d10f6d0e2bb9bb13e085d69d6ece92
|
||||
README.zh.md: 0204494c7856b96d6f0c17c580abea31e49c3516
|
||||
README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da
|
||||
README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f
|
||||
|
||||
@@ -47,6 +47,10 @@ The add flow is a card carrying the dormant-directory provider select — a bare
|
||||
|
||||
After the versioned notice step completes, the DeepSeek step projects first-run readiness from the same joined snapshot. ANY provider the user can already reach ends it without rendering; only a user with none is asked for the official DeepSeek key. Configure later completes only this coordinator pass, and an absent adapter, inactive route, failed join, read-only deployment, or unusable capability completes the step without rendering — Models remains the diagnostic surface.
|
||||
|
||||
### Extension slots
|
||||
|
||||
The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -47,6 +47,10 @@ kind: "package-reference"
|
||||
|
||||
版本化声明步骤完成后,DeepSeek 步骤从同一份合并快照投影首次运行就绪状态。用户已经能够到达的**任何**提供方都会直接结束该步骤、不做渲染;只有没有任何提供方的用户才会被询问官方 DeepSeek 密钥。「稍后配置」只完成这次协调器遍历;适配器缺失、路由不活动、合并失败、只读部署或能力不可用时,该步骤不渲染即完成——Models 仍是诊断界面。
|
||||
|
||||
### 扩展插槽
|
||||
|
||||
本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -16,7 +16,9 @@ import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls this package's SlotMap merge (the two Models child slots).
|
||||
import type {} from './slot-contract.ts'
|
||||
import { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts'
|
||||
import type { ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
@@ -42,11 +44,20 @@ export interface ModelsSectionInjected {
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** The child slots this section declares and dispatches (see ./slot-contract.ts). */
|
||||
type ModelsChildSlots = 'settings.models.provider-card' | 'settings.models.footer'
|
||||
|
||||
/** The child-slot dispatch function the renderer binds for the section. */
|
||||
type ModelsRenderSlot = PropsRenderSlots<ModelsChildSlots>['renderSlot']
|
||||
|
||||
/**
|
||||
* Props delivered by the slot outlet: the inject face spread flat (the
|
||||
* renderer erases the share boundary at the render call).
|
||||
* renderer erases the share boundary at the render call) plus the child-slot
|
||||
* dispatch seat. The seat is required: the renderer binds it at the render
|
||||
* call itself — unlike the inject face it is never absent at runtime — and a
|
||||
* direct render that forgets it fails to compile instead of mounting nothing.
|
||||
*/
|
||||
export type ModelsSectionProps = Partial<InjectFace<ModelsSectionInjected>>
|
||||
export type ModelsSectionProps = Partial<InjectFace<ModelsSectionInjected>> & PropsRenderSlots<ModelsChildSlots>
|
||||
|
||||
type ModelsSectionFace = InjectFace<ModelsSectionInjected>
|
||||
|
||||
@@ -139,6 +150,19 @@ export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean {
|
||||
return row.credential?.configured !== true
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider-card seat's credential fact: the reference this page would use
|
||||
* for the row — the profile's `apiKeyEnv`, or the page's derived
|
||||
* `<ROUTE>_API_KEY` while the profile names none — confirmed configured. The
|
||||
* derived half is what keeps the seat consistent with the editor on the
|
||||
* add-provider draft, whose dormant row names no reference yet.
|
||||
*/
|
||||
function keyConfiguredOf(row: ProviderRow): boolean {
|
||||
return row.apiKeyEnv !== undefined
|
||||
? row.credential?.configured === true
|
||||
: row.derivedCredential?.configured === true
|
||||
}
|
||||
|
||||
function targetOf(row: ProviderRow): EditorTarget {
|
||||
const managedRef = deriveKeyRef(row.entry.provider)
|
||||
const credentialRef = row.apiKeyEnv === managedRef
|
||||
@@ -175,15 +199,15 @@ export function providerCopy(template: string, target: ProviderIdentity): string
|
||||
* @returns the section, or null while the shell has not injected yet.
|
||||
*/
|
||||
export function ModelsSection(props: ModelsSectionProps): ReactNode {
|
||||
const { controller, useSnapshot, api, schema, t } = props
|
||||
const { controller, useSnapshot, api, schema, t, renderSlot } = props
|
||||
if (
|
||||
controller === undefined || useSnapshot === undefined || api === undefined
|
||||
|| schema === undefined || t === undefined
|
||||
) return null
|
||||
return <Loaded injected={{ controller, useSnapshot, api, schema, t }} />
|
||||
return <Loaded injected={{ controller, useSnapshot, api, schema, t }} renderSlot={renderSlot} />
|
||||
}
|
||||
|
||||
function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode {
|
||||
const { controller, api, schema, t } = injected
|
||||
const state = injected.useSnapshot(snapshot => snapshot)
|
||||
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
|
||||
@@ -275,6 +299,12 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
|
||||
const addTarget = adding ? editing : undefined
|
||||
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
|
||||
// The draft's directory row, for the card extension seat. A refresh can drop
|
||||
// the row mid-draft (the route was adopted or withdrawn elsewhere); the
|
||||
// draft card stays while the seat simply has no row to dispatch.
|
||||
const addRow = addTarget === undefined
|
||||
? undefined
|
||||
: state.rows.find(row => row.entry.provider === addTarget.provider)
|
||||
// Hand-declared routes live in the pi-ai namespace, which is also the only
|
||||
// one whose schema names the protocols one may speak; without it mounted
|
||||
// there is nothing to declare and the entry point stays disabled.
|
||||
@@ -324,6 +354,11 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeSetup(changed, target) },
|
||||
})}
|
||||
{renderSlot(
|
||||
'settings.models.provider-card',
|
||||
{ provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) },
|
||||
{ entryKey: row.entry.settingsNs },
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -399,6 +434,11 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
{renderSlot(
|
||||
'settings.models.provider-card',
|
||||
{ provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) },
|
||||
{ entryKey: row.entry.settingsNs },
|
||||
)}
|
||||
{open
|
||||
? renderProviderEditor({
|
||||
target,
|
||||
@@ -449,6 +489,13 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
readOnly={!state.writable}
|
||||
onClose={(changed) => { closeEditor(changed, addTarget) }}
|
||||
/>
|
||||
{addRow === undefined
|
||||
? null
|
||||
: renderSlot(
|
||||
'settings.models.provider-card',
|
||||
{ provider: addRow.entry, configured: addRow.configured, keyConfigured: keyConfiguredOf(addRow) },
|
||||
{ entryKey: addRow.entry.settingsNs },
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: declaring
|
||||
@@ -509,6 +556,7 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderSlot('settings.models.footer', {})}
|
||||
<Modal
|
||||
open={deleteTarget !== undefined}
|
||||
onClose={closeDelete}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { en, zh, type ModelsKey } from './locales.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
|
||||
|
||||
export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
|
||||
export type { ModelsFooterOwnerProps, ProviderCardExtrasOwnerProps } from './slot-contract.ts'
|
||||
export type { ModelsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -125,6 +126,10 @@ export function apply(ctx: ClientContext): void {
|
||||
order: 10,
|
||||
label: () => t('nav'),
|
||||
inject: injected,
|
||||
children: {
|
||||
'settings.models.provider-card': { kind: 'keyed', scope: 'root' },
|
||||
'settings.models.footer': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
}, ModelsSection))
|
||||
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Models-page extension slots — the two seats through which a plugin
|
||||
* distributed outside this repository adds UI to the Models settings section
|
||||
* without editing it.
|
||||
*
|
||||
* `settings.models.provider-card` is keyed by the row's owning settings
|
||||
* namespace (`ConfigurableProviderView.settingsNs`): an adapter family's
|
||||
* companion plugin registers one entry under the family's namespace and
|
||||
* receives every card of that family — shipped, added, and hand-declared rows
|
||||
* alike — while the section never learns what the namespace means. Keying on
|
||||
* the namespace follows `settings.plugin.item`, and the key domain stays the
|
||||
* open string space because hand-declared route ids are user-chosen at
|
||||
* runtime.
|
||||
*
|
||||
* TYPE HOME RATIONALE: the Models section declares these slots at runtime,
|
||||
* and a plugin registering an extension already depends on this package for
|
||||
* the declaration. The types therefore live with their declarer.
|
||||
*/
|
||||
|
||||
import type { ConfigurableProviderView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
* One provider card's adapter extension area, dispatched with
|
||||
* `entryKey = settingsNs` on every card that renders a directory row: a
|
||||
* saved row's card (its first-run setup posture included) and the
|
||||
* add-provider draft card. The hand-declared draft card has no directory
|
||||
* row yet, so it dispatches nothing until saved. Without a registrant the
|
||||
* area renders nothing.
|
||||
*/
|
||||
'settings.models.provider-card': { kind: 'keyed'; scope: 'root'; owner: ProviderCardExtrasOwnerProps }
|
||||
/**
|
||||
* Ordered extension area after the provider rows and the add controls.
|
||||
* Without a registrant the area renders nothing.
|
||||
*/
|
||||
'settings.models.footer': { kind: 'list'; scope: 'root'; owner: ModelsFooterOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of one provider-card extension occurrence. */
|
||||
export interface ProviderCardExtrasOwnerProps {
|
||||
/** The card's directory row (route id, display name, settings address, live state). */
|
||||
provider: ConfigurableProviderView
|
||||
/** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */
|
||||
configured: boolean
|
||||
/** Whether the row's referenced api-key credential is confirmed configured (the page's credential join). */
|
||||
keyConfigured: boolean
|
||||
}
|
||||
|
||||
/** Owner share of the footer area (the section supplies nothing). */
|
||||
export interface ModelsFooterOwnerProps {
|
||||
/** Marker field: footer owner props are intentionally empty. */
|
||||
children?: never
|
||||
}
|
||||
@@ -32,6 +32,13 @@ export interface ProviderRow {
|
||||
apiKeyEnv: string | undefined
|
||||
/** Credential state for {@link apiKeyEnv}, once described. */
|
||||
credential: CredentialView | undefined
|
||||
/**
|
||||
* Credential state for the page's derived `<ROUTE>_API_KEY`, described only
|
||||
* while the profile names no reference — the provider-card seat's
|
||||
* `keyConfigured` fact for dormant and keyless rows, matching the editor's
|
||||
* own derivation rule.
|
||||
*/
|
||||
derivedCredential?: CredentialView
|
||||
}
|
||||
|
||||
/** Page snapshot. */
|
||||
@@ -185,7 +192,7 @@ export class ModelsSettingsStore {
|
||||
credential: undefined,
|
||||
}
|
||||
})
|
||||
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
|
||||
const refs = [...new Set(rows.map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))]
|
||||
let credentials: Record<string, CredentialView> = {}
|
||||
let credentialError: string | null = null
|
||||
if (refs.length > 0) {
|
||||
@@ -206,12 +213,15 @@ export class ModelsSettingsStore {
|
||||
s.error = null
|
||||
s.credentialError = credentialError
|
||||
s.writable = writable
|
||||
s.rows = rows.map(row => ({
|
||||
...row,
|
||||
...row.apiKeyEnv !== undefined && credentials[row.apiKeyEnv] !== undefined
|
||||
? { credential: credentials[row.apiKeyEnv] }
|
||||
: {},
|
||||
}))
|
||||
s.rows = rows.map((row) => {
|
||||
const named = row.apiKeyEnv === undefined ? undefined : credentials[row.apiKeyEnv]
|
||||
const derived = row.apiKeyEnv !== undefined ? undefined : credentials[deriveKeyRef(row.entry.provider)]
|
||||
return {
|
||||
...row,
|
||||
...named === undefined ? {} : { credential: named },
|
||||
...derived === undefined ? {} : { derivedCredential: derived },
|
||||
}
|
||||
})
|
||||
s.namespaces = namespaces
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ describe('ui-settings-models apply', () => {
|
||||
const entry = before.slots.entries('settings.section')[0]!
|
||||
expect(entry.component).toBe(ModelsSection)
|
||||
expect(entry.options).toMatchObject({ id: 'models', order: 10 })
|
||||
// The section claims its two extension seats in the same registration.
|
||||
expect(before.slots.spec('settings.models.provider-card')).toMatchObject({ kind: 'keyed', scope: 'root' })
|
||||
expect(before.slots.spec('settings.models.footer')).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
// The nav label is a locale-following thunk; owners resolve at read time.
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
|
||||
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
|
||||
@@ -137,6 +140,28 @@ describe('ui-settings-models apply', () => {
|
||||
b.locale.setLocale('zh')
|
||||
})
|
||||
|
||||
it('accepts extension entries under the declared seats and cascades them with the declarer', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
// A keyed card extension and a footer entry register through the ordinary
|
||||
// ledger once the section's registration declared the seats.
|
||||
const disposeCard = b.slots.register(
|
||||
{ name: 'settings.models.provider-card', key: 'llm-pi-ai' } as never,
|
||||
() => null,
|
||||
)
|
||||
b.slots.register({ name: 'settings.models.footer', id: 'extra', order: 0 } as never, () => null)
|
||||
expect(b.slots.entries('settings.models.provider-card')).toHaveLength(1)
|
||||
expect(b.slots.entries('settings.models.footer')).toHaveLength(1)
|
||||
// Extension-side HMR safety: its own disposer removes the entry.
|
||||
disposeCard()
|
||||
expect(b.slots.entries('settings.models.provider-card')).toHaveLength(0)
|
||||
// Declarer unload cascades whatever extension entries remain.
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('settings.models.footer')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers the zh/en nav dictionaries and disposes everything with the fiber', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
|
||||
@@ -195,20 +195,44 @@ function scriptedFace(overrides: {
|
||||
|
||||
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
||||
|
||||
/** One recorded child-slot dispatch: seat name, owner share, kind options. */
|
||||
type RenderSlotCall = [name: string, owner: Record<string, unknown>, opts?: { entryKey?: string }]
|
||||
|
||||
/** Child-slot dispatch stub: records every seat occurrence, renders nothing. */
|
||||
function stubRenderSlot() {
|
||||
return vi.fn((..._call: RenderSlotCall) => null)
|
||||
}
|
||||
|
||||
/** The provider-card seat dispatches a stub recorded, as (route id, configured, keyConfigured, entryKey). */
|
||||
function cardSeatCalls(
|
||||
renderSlot: ReturnType<typeof stubRenderSlot>,
|
||||
): Array<[string, boolean, boolean, string | undefined]> {
|
||||
return renderSlot.mock.calls
|
||||
.filter(call => call[0] === 'settings.models.provider-card')
|
||||
.map(call => [
|
||||
(call[1] as { provider: { provider: string } }).provider.provider,
|
||||
(call[1] as { configured: boolean }).configured,
|
||||
(call[1] as { keyConfigured: boolean }).keyConfigured,
|
||||
call[2]?.entryKey,
|
||||
])
|
||||
}
|
||||
|
||||
async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
|
||||
const { face, update, replace, mutate, set, unset } = scripted
|
||||
const mirror = new SettingsDescribeMirror(face as never)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror)
|
||||
await controller.load()
|
||||
const renderSlot = stubRenderSlot()
|
||||
const injected: ModelsSectionProps = {
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
api: face as never,
|
||||
schema: settingsSchema,
|
||||
t,
|
||||
renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'],
|
||||
}
|
||||
const view = render(<ModelsSection {...injected} />)
|
||||
return { view, face, update, replace, mutate, set, unset, controller, mirror }
|
||||
return { view, face, update, replace, mutate, set, unset, controller, mirror, renderSlot }
|
||||
}
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
@@ -246,6 +270,61 @@ describe('ModelsSection', () => {
|
||||
expect(document.body.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('dispatches the provider-card seat per rendered row, keyed by the owning namespace', async () => {
|
||||
const { renderSlot } = await mountSection()
|
||||
const cards = cardSeatCalls(renderSlot)
|
||||
expect(cards).toContainEqual(['openai', true, true, 'llm-pi-ai'])
|
||||
expect(cards).toContainEqual(['deepseek-official', true, false, 'llm-deepseek'])
|
||||
// The footer seat renders once below the rows and the add controls.
|
||||
expect(renderSlot.mock.calls.filter(call => call[0] === 'settings.models.footer')).toEqual([
|
||||
['settings.models.footer', {}],
|
||||
])
|
||||
})
|
||||
|
||||
it('dispatches the provider-card seat inside the first-run setup card', async () => {
|
||||
const { renderSlot } = await mountFirstRun()
|
||||
expect(cardSeatCalls(renderSlot)).toContainEqual(['deepseek-official', true, false, 'llm-deepseek'])
|
||||
})
|
||||
|
||||
it('dispatches the provider-card seat on the add-provider draft with its dormant row', async () => {
|
||||
const { renderSlot } = await mountSection()
|
||||
renderSlot.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
expect(cardSeatCalls(renderSlot)).toContainEqual(['anthropic', false, false, 'llm-pi-ai'])
|
||||
})
|
||||
|
||||
it('derives the draft seat\'s key fact from the page\'s conventional reference', async () => {
|
||||
const scripted = scriptedFace()
|
||||
scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
|
||||
configured: ref === 'OPENAI_API_KEY' || ref === 'ANTHROPIC_API_KEY',
|
||||
writable: true,
|
||||
}])),
|
||||
})))
|
||||
const { renderSlot } = await mountFace(scripted)
|
||||
renderSlot.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
// The dormant row names no reference yet; the seat still reports the
|
||||
// derived ANTHROPIC_API_KEY the editor itself displays as configured.
|
||||
expect(cardSeatCalls(renderSlot)).toContainEqual(['anthropic', false, true, 'llm-pi-ai'])
|
||||
})
|
||||
|
||||
it('skips the draft seat when a refresh drops the dormant row', async () => {
|
||||
const { renderSlot, face, controller } = await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
face.llm.providers.mockImplementation(() => Promise.resolve(ok({
|
||||
providers: [
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
||||
],
|
||||
})))
|
||||
renderSlot.mockClear()
|
||||
await act(async () => { await controller.load() })
|
||||
// The draft card is still open while its row is gone from the directory.
|
||||
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
|
||||
expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false)
|
||||
})
|
||||
|
||||
it('persists the default-off subagent model-selection switch for new sessions', async () => {
|
||||
const enabledNamespace: SettingsNamespaceView = {
|
||||
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
|
||||
@@ -352,6 +431,7 @@ describe('ModelsSection', () => {
|
||||
api={face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={() => null}
|
||||
/>)
|
||||
|
||||
const missing = screen.getByRole('img', { name: en.credentialMissing })
|
||||
@@ -376,6 +456,7 @@ describe('ModelsSection', () => {
|
||||
api={face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={() => null}
|
||||
/>)
|
||||
// Now a row with an Edit button, not an open card.
|
||||
expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
|
||||
@@ -1106,6 +1187,7 @@ describe('ModelsSection', () => {
|
||||
api={face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={() => null}
|
||||
/>)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(key.placeholder).toBe(en.keyPlaceholder)
|
||||
@@ -1245,6 +1327,7 @@ describe('ModelsSection', () => {
|
||||
api={face.face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={() => null}
|
||||
/>)
|
||||
expect(screen.getByText(/directory down/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByText(en.retry))
|
||||
@@ -1267,6 +1350,7 @@ describe('ModelsSection', () => {
|
||||
api={face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={() => null}
|
||||
/>)
|
||||
expect(screen.getByText(en.readOnly)).toBeTruthy()
|
||||
expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
|
||||
@@ -1328,6 +1412,7 @@ describe('ModelsSection', () => {
|
||||
api={face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={() => null}
|
||||
/>)
|
||||
await screen.findByText('DeepSeek')
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-settings-models/invariant'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
@@ -18,6 +19,6 @@ describe('invariant companion', () => {
|
||||
})
|
||||
|
||||
it('renders null until the shell injects the section dependencies', () => {
|
||||
expect(ModelsSection({})).toBeNull()
|
||||
expect(ModelsSection({} as ModelsSectionProps)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,6 +150,7 @@ async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
api: scripted.face as never,
|
||||
schema: settingsSchema,
|
||||
t,
|
||||
renderSlot: () => null,
|
||||
}
|
||||
render(<ModelsSection {...injected} />)
|
||||
return { ...scripted, controller }
|
||||
@@ -671,6 +672,7 @@ describe('provider rows', () => {
|
||||
api={scripted.face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={() => null}
|
||||
/>)
|
||||
|
||||
// Absent is "unknown", never "shipped": an adapter that answers nothing
|
||||
|
||||
@@ -81,7 +81,9 @@ describe('ModelsSettingsStore', () => {
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.writable).toBe(true)
|
||||
expect(state.credentialError).toBeNull()
|
||||
expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
|
||||
// Named references first (rows order), then the derived <ROUTE>_API_KEY
|
||||
// of every row whose profile names none — one batched describe.
|
||||
expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GHOST_API_KEY']])
|
||||
const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
|
||||
expect(byProvider.get('deepseek-official')).toMatchObject({
|
||||
configured: true,
|
||||
@@ -196,7 +198,7 @@ describe('edge joins', () => {
|
||||
expect(state.rows[0]?.apiKeyEnv).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips the credential describe entirely when no row names a reference', async () => {
|
||||
it('describes the derived reference for a row whose profile names none', async () => {
|
||||
const { face, mirror, seenRefs } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
@@ -208,11 +210,19 @@ describe('edge joins', () => {
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||||
] as never,
|
||||
})),
|
||||
describeCredentials: refs => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])),
|
||||
})),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
|
||||
await store.load()
|
||||
expect(seenRefs).toEqual([])
|
||||
expect(store.store.getSnapshot().status).toBe('ready')
|
||||
// The dormant row names no reference, so the join asks about the page's
|
||||
// own derived <ROUTE>_API_KEY — what the editor would display for it.
|
||||
expect(seenRefs).toEqual([['ANTHROPIC_API_KEY']])
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.rows[0]?.credential).toBeUndefined()
|
||||
expect(state.rows[0]?.derivedCredential).toMatchObject({ configured: true })
|
||||
})
|
||||
|
||||
it('surfaces a settings describe failure', async () => {
|
||||
|
||||
@@ -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-user-questions/README.md
|
||||
README.md: 51095ef4b8946657d786abddb5dc01cbd462eddb
|
||||
README.zh.md: ec91254b1ace817ce2e2b667870e7512e2879a2d
|
||||
README.md: 2462a3d25644cf073b4652d564a9f7a213e92250
|
||||
README.zh.md: 296974703d77791393811b89fd046078969584b5
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-client-ui-user-questions` is the web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot, so when the agent asks the user a question the composer is taken over by the question UI. The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges, and custom answers, and submits one structured answer batch for the whole request. A request whose single question declares a presentation intent renders as that intent's own surface instead — notably the `plan-review` waiting-approval card with `Chat about it` / `Refuse` / `Approve`. Its host half is empty on purpose: mounting `dsh-tool-ask-user` there would put the tool in the registry's global layer and merge it into every agent regardless of the preset that composed it.
|
||||
`dsh-client-ui-user-questions` is the web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` chain, so when the agent asks the user a question the composer is taken over by the question UI. The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges, and custom answers, and submits one structured answer batch for the whole request. A request whose single question declares a presentation intent renders as that intent's own surface instead — notably the `plan-review` waiting-approval card with `Chat about it` / `Refuse` / `Approve`. Its host half is empty on purpose: mounting `dsh-tool-ask-user` there would put the tool in the registry's global layer and merge it into every agent regardless of the preset that composed it.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -29,15 +29,15 @@ When the agent asks a question, the composer becomes the question surface: answe
|
||||
|
||||
### Answering
|
||||
|
||||
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
### The plan-review card
|
||||
|
||||
A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead.
|
||||
A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card layout: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead.
|
||||
|
||||
### Failure and recovery
|
||||
|
||||
Selection state is local to a component keyed by the request rpcId: a replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
|
||||
The generic question flow keeps its current page, selected labels, custom text, and explicit skips in a non-persisted Slot store scoped to the owning Session and keyed by the pending request's local render identity. Switching from Session A to B remounts the strict composer entry, but returning to A reuses A's store and restores the unfinished draft. A different request identity reads an empty draft and replaces the previous value on its first edit; a successful answer or cancellation clears the matching value. The host remains authoritative for whether the request is pending.
|
||||
|
||||
-----
|
||||
|
||||
@@ -76,7 +76,7 @@ These pages cover the composer host, the tool seam, and the plan-mode consumer.
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through dsh-tool-ask-user, which the package mounts and which owns the model-visible schema and answer rendering.
|
||||
Indirectly, through `dsh-tool-ask-user`, whose model-visible schema and answer rendering this package presents in the Web client.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -89,7 +89,7 @@ No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and
|
||||
|
||||
These limits define draft durability and composer ownership; they are current package constraints.
|
||||
|
||||
- **Unsubmitted drafts are not durable** — reconnect resync or a full page reload restores the host-owned pending request with the same rpcId, but a composer unmount resets local option and custom-text drafts.
|
||||
- **Unsubmitted drafts have page-and-Session lifetime** — Session navigation preserves them while that Session scope remains in the page, but a full page reload, Session pruning, or a newly delivered pending-request identity starts with an empty draft. The store never writes them to the Host, `localStorage`, or disk.
|
||||
- **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-client-ui-user-questions` 是 Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` 键控 slot 中,因此当 agent 向用户提问时,编辑器会被提问 UI 接管。组件每次渲染一个问题,提供进度导航、单选与多选选项、推荐徽标与自定义答案,并为整个请求提交一批结构化答案。若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面——最典型的是 `plan-review` 等待审批卡片,带 `Chat about it` / `Refuse` / `Approve`。其主机侧刻意为空:在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的全局层,并把它并入每一个 agent,无论它由哪个 preset 组装。
|
||||
`dsh-client-ui-user-questions` 是 Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` chain 中,因此当 agent 向用户提问时,编辑器会被提问 UI 接管。组件每次渲染一个问题,提供进度导航、单选与多选选项、推荐徽标与自定义答案,并为整个请求提交一批结构化答案。若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面——最典型的是 `plan-review` 等待审批卡片,带 `Chat about it` / `Refuse` / `Approve`。其主机侧刻意为空:在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的全局层,并把它并入每一个 agent,无论它由哪个 preset 组装。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -29,15 +29,15 @@ kind: "package-reference"
|
||||
|
||||
### 作答
|
||||
|
||||
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
### plan-review 卡片
|
||||
|
||||
`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。
|
||||
`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染等待审批卡片的布局:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。
|
||||
|
||||
### 失败与恢复
|
||||
|
||||
选择状态只存在于以请求 rpcId 为 key 的组件本地:使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。
|
||||
通用提问流程把当前题号、已选标签、自定义文本和显式跳过状态保存在非持久化 Slot store 中;该 store 归属对应 Session,并以待处理请求的本地渲染标识为 key。从 Session A 切换到 B 会重新挂载严格 Session 级编辑器条目,但返回 A 时会复用 A 的 store 并恢复未完成草稿。不同的请求标识读取空草稿,并在首次编辑时替换旧值;成功回答或取消会清除相符的值。请求是否仍在等待由主机保持权威。
|
||||
|
||||
-----
|
||||
|
||||
@@ -76,7 +76,7 @@ kind: "package-reference"
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
间接影响模型体验:通过 `dsh-tool-ask-user` 实现,本包挂载该工具,而该工具拥有模型可见的 schema 与答案渲染。
|
||||
间接影响模型体验:本包在 Web 客户端呈现 `dsh-tool-ask-user` 所拥有的模型可见 schema 与答案渲染。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -89,7 +89,7 @@ kind: "package-reference"
|
||||
|
||||
这些限制定义草稿持久性与编辑器归属;它们是当前包约束。
|
||||
|
||||
- **未提交的草稿不持久**:重新连接再同步或完整刷新页面时,会恢复主机拥有且 rpcId 相同的待处理请求,但编辑器卸载会重置本地选项和自定义文本草稿。
|
||||
- **未提交草稿的生命周期限于当前页面与 Session**:只要该 Session scope 仍留在页面内,Session 导航就会保留草稿;完整刷新页面、Session 被裁剪,或待处理请求以新的本地标识重新交付时,则从空草稿开始。store 从不把草稿写入主机、`localStorage` 或磁盘。
|
||||
- **每次只有一个请求拥有编辑器**:后续待处理请求仍留在会话快照中,并在较早请求落定后显示。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-user-questions",
|
||||
"description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI",
|
||||
"description": "Web ask_user_question composer takeover and plan-review presentation UI",
|
||||
"version": "0.1.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -69,6 +69,7 @@
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -10,15 +10,10 @@ import {
|
||||
type QuestionAnswer, type QuestionComposerProps,
|
||||
} from './contract/slots.ts'
|
||||
import type { PendingQuestion } from './contract/slots.ts'
|
||||
import type { QuestionDraftAnswer, QuestionDraftProgress } from './draft-store.ts'
|
||||
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
|
||||
import css from './QuestionComposer.module.css'
|
||||
|
||||
interface DraftAnswer {
|
||||
selected: string[]
|
||||
custom: string
|
||||
skipped: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Displayed feedback: validation feedback is stored as a dictionary KEY and
|
||||
* translated at render, so already-shown feedback follows a locale switch;
|
||||
@@ -46,9 +41,9 @@ function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
||||
}
|
||||
|
||||
/** The free-text answer field shared by both question shapes. */
|
||||
/** The free-text answer field shared by both question variants. */
|
||||
interface AnswerFieldProps {
|
||||
/** Which shape the field takes: the custom row's inline column, or the optionless question's own framed block. */
|
||||
/** Visual variant: the custom row's inline column or the optionless question's framed block. */
|
||||
variant: 'inline' | 'block'
|
||||
/** Current draft text. */
|
||||
value: string
|
||||
@@ -79,7 +74,7 @@ interface AnswerFieldProps {
|
||||
* Mirror and textarea MUST share font, line-height, padding and wrapping rules
|
||||
* or the two heights diverge.
|
||||
*
|
||||
* @param props - field shape, draft text, and the field's event handlers.
|
||||
* @param props - visual variant, draft text, and the field's event handlers.
|
||||
* @returns The mirrored auto-growing field.
|
||||
*/
|
||||
function AnswerField(props: AnswerFieldProps) {
|
||||
@@ -102,14 +97,15 @@ function AnswerField(props: AnswerFieldProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer takeover boundary; the carrier key keys local drafts, so a
|
||||
* same-request replay (same key, new carrier object) preserves them.
|
||||
* Composer takeover router. Generic-question drafts live in this entry's
|
||||
* Session-scoped Slot store, keyed by the pending carrier, so a strict Session
|
||||
* entry remount restores the same request without exposing it to another one.
|
||||
*
|
||||
* One takeover, two shapes: a request that declares a presentation intent this
|
||||
* package renders takes that shape (a plan review is one decision over one
|
||||
* One takeover, two presentations: a request that declares a presentation intent this
|
||||
* package renders uses that presentation (a plan review is one decision over one
|
||||
* plan, not a question set), and every other request takes the generic flow.
|
||||
* The routing lives here, at the one entry that owns the composer seat, so
|
||||
* neither shape can claim a request the other is already rendering.
|
||||
* neither presentation can claim a request the other is already rendering.
|
||||
*
|
||||
* @param props - the selector-matched pending question carrier plus the framework standard kit.
|
||||
* @returns The question flow, or the intent's own surface, for this request.
|
||||
@@ -118,47 +114,74 @@ export function QuestionComposer(props: QuestionComposerProps) {
|
||||
const question = props.matched
|
||||
const review = useMemo(() => planReviewOf(question.questions), [question])
|
||||
return review === undefined
|
||||
? <QuestionFlow key={question.key} pending={question} t={props.t} />
|
||||
? (
|
||||
<QuestionFlow
|
||||
key={question.key}
|
||||
pending={question}
|
||||
t={props.t}
|
||||
useStore={props.useStore}
|
||||
actions={props.actions}
|
||||
/>
|
||||
)
|
||||
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
|
||||
}
|
||||
|
||||
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
|
||||
type QuestionFlowProps =
|
||||
{ pending: PendingQuestion } & Pick<QuestionComposerProps, 't' | 'useStore' | 'actions'>
|
||||
|
||||
function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) {
|
||||
const questions = pending.questions
|
||||
const markdownLabels = useMemo(() => ({
|
||||
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
|
||||
footnotes: t('markdown.footnotes'),
|
||||
}), [t])
|
||||
const [index, setIndex] = useState(0)
|
||||
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
|
||||
selected: [], custom: '', skipped: false,
|
||||
})))
|
||||
const initialProgress = useMemo<QuestionDraftProgress>(() => ({
|
||||
index: 0,
|
||||
drafts: questions.map(() => ({ selected: [], custom: '', skipped: false })),
|
||||
}), [questions])
|
||||
const storedProgress = useStore(state => (
|
||||
state.requestKey === pending.key && state.progress.drafts.length === questions.length
|
||||
? state.progress
|
||||
: undefined
|
||||
))
|
||||
const { index, drafts } = storedProgress ?? initialProgress
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
const [error, setError] = useState<Feedback | null>(null)
|
||||
// Collapsed to the header strip so the conversation above stays readable
|
||||
// while the user decides; the drafts survive because the state lives here.
|
||||
// while the user decides; answer drafts live in the Session store above.
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
// The free-form textarea autofocuses on first presentation; re-expanding a
|
||||
// collapsed question must not steal focus from the expand toggle back into
|
||||
// the input, so focus is granted once per question index.
|
||||
const focusedQuestions = useRef(new Set<number>())
|
||||
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
||||
// Every navigation write stays in bounds and drafts mirrors questions 1:1.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const question = questions[index]!
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const draft = drafts[index]!
|
||||
const hasOptions = (question.options?.length ?? 0) > 0
|
||||
|
||||
const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => {
|
||||
actions.replace(pending.key, { index: nextIndex, drafts: nextDrafts })
|
||||
}
|
||||
|
||||
const cancelFlow = (): void => {
|
||||
setBusy('cancel')
|
||||
setError(null)
|
||||
void pending.cancel().catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
void pending.cancel()
|
||||
.then(() => { actions.clear(pending.key) })
|
||||
.catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
}
|
||||
|
||||
const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
|
||||
setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
|
||||
const updateDraft = (
|
||||
update: (current: QuestionDraftAnswer) => QuestionDraftAnswer,
|
||||
nextIndex = index,
|
||||
): void => {
|
||||
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index ? update(item) : item)
|
||||
replaceProgress(nextIndex, nextDrafts)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
@@ -171,27 +194,24 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
return { ...current, selected, skipped: false }
|
||||
}
|
||||
return { selected: [label], custom: '', skipped: false }
|
||||
})
|
||||
if (question.multiSelect !== true && index < questions.length - 1) {
|
||||
setIndex(current => current + 1)
|
||||
}
|
||||
}, question.multiSelect !== true && index < questions.length - 1 ? index + 1 : index)
|
||||
}
|
||||
|
||||
const answered = (item: DraftAnswer): boolean =>
|
||||
const answered = (item: QuestionDraftAnswer): boolean =>
|
||||
item.selected.length > 0 || item.custom.trim() !== ''
|
||||
|
||||
const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
|
||||
const completed = (item: QuestionDraftAnswer): boolean => answered(item) || item.skipped
|
||||
|
||||
const submitDrafts = (values: DraftAnswer[]): void => {
|
||||
const submitDrafts = (values: QuestionDraftAnswer[]): void => {
|
||||
const missing = values.findIndex(item => !completed(item))
|
||||
if (missing >= 0) {
|
||||
setIndex(missing)
|
||||
replaceProgress(missing, values)
|
||||
setError({ key: 'error.incomplete' })
|
||||
return
|
||||
}
|
||||
const answer: QuestionAnswer = {
|
||||
answers: questions.map((item, itemIndex) => {
|
||||
const value = values[itemIndex] as DraftAnswer
|
||||
const value = values[itemIndex] as QuestionDraftAnswer
|
||||
if (value.skipped) return { id: item.id, selected: [] }
|
||||
const custom = value.custom.trim()
|
||||
return {
|
||||
@@ -203,10 +223,12 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
}
|
||||
setBusy('answer')
|
||||
setError(null)
|
||||
void pending.answer(answer).catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
void pending.answer(answer)
|
||||
.then(() => { actions.clear(pending.key) })
|
||||
.catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
}
|
||||
|
||||
const continueFlow = (): void => {
|
||||
@@ -215,7 +237,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
return
|
||||
}
|
||||
if (index < questions.length - 1) {
|
||||
setIndex(current => current + 1)
|
||||
replaceProgress(index + 1, drafts)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
@@ -245,10 +267,9 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
||||
? { selected: [], custom: '', skipped: true }
|
||||
: item)
|
||||
setDrafts(nextDrafts)
|
||||
replaceProgress(index < questions.length - 1 ? index + 1 : index, nextDrafts)
|
||||
setError(null)
|
||||
if (index < questions.length - 1) {
|
||||
setIndex(current => current + 1)
|
||||
return
|
||||
}
|
||||
submitDrafts(nextDrafts)
|
||||
@@ -382,7 +403,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.prev')}
|
||||
disabled={index === 0 || busy !== null}
|
||||
onClick={() => { setIndex(index - 1); setError(null) }}
|
||||
onClick={() => { replaceProgress(index - 1, drafts); setError(null) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
@@ -390,7 +411,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.next')}
|
||||
disabled={index === questions.length - 1 || busy !== null}
|
||||
onClick={() => { setIndex(index + 1); setError(null) }}
|
||||
onClick={() => { replaceProgress(index + 1, drafts); setError(null) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Question composer props and one pending Remote waterfall response. */
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// The client module declares the conversation.composer SlotMap entry required by PropsRuntime.
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
AskUserQuestionAnswer, AskUserQuestionItem,
|
||||
} from '@deepseek-ai/dsh-user-questions'
|
||||
import type { createQuestionDraftStore } from '../draft-store.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-session/client' {
|
||||
interface SessionPendingInteractionMap {
|
||||
@@ -38,7 +39,7 @@ function settlePendingComposer(settle: () => void, failureMessage: string): Prom
|
||||
/**
|
||||
* A request narrowed to the `plan-review` presentation intent: everything the
|
||||
* decision card renders and answers with, so the panel never re-reads the
|
||||
* request shape. `approve` and `decline` are the asker's own options — an
|
||||
* request fields. `approve` and `decline` are the asker's own options — an
|
||||
* answer must carry one of those labels verbatim — and `plan` is the markdown
|
||||
* body under review.
|
||||
*/
|
||||
@@ -108,7 +109,7 @@ function questionError(message: string, code: 'ASK_ABORTED' | 'ASK_CANCELLED'):
|
||||
export class PendingQuestion {
|
||||
/** Presentation discriminator used by Session pending-interaction consumers. */
|
||||
readonly kind: 'question' | 'plan-review'
|
||||
/** Opaque render identity and local-draft remount axis. */
|
||||
/** Opaque render identity and request key for the Session-scoped draft store. */
|
||||
readonly key: string
|
||||
/** The request's question list. */
|
||||
readonly questions: readonly AskUserQuestionItem[]
|
||||
@@ -217,4 +218,7 @@ export type QuestionWait = PendingQuestion
|
||||
* whole behavior surface.
|
||||
*/
|
||||
export type QuestionComposerProps =
|
||||
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>
|
||||
PropsRuntime<'conversation.composer'>
|
||||
& PropsStore<ReturnType<typeof createQuestionDraftStore>>
|
||||
& { matched: QuestionWait }
|
||||
& PropsLocale<'question'>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Session-scoped draft state for the generic question composer. The Slot
|
||||
* registry owns store instances; this module exports only the factory so a
|
||||
* plugin reload cannot reuse a module-global handle.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
|
||||
|
||||
/** One in-progress answer, including an explicit skip. */
|
||||
export interface QuestionDraftAnswer {
|
||||
/** Offered labels currently selected. */
|
||||
selected: string[]
|
||||
/** Human-authored alternative or additional answer. */
|
||||
custom: string
|
||||
/** Whether the user explicitly skipped this question. */
|
||||
skipped: boolean
|
||||
}
|
||||
|
||||
/** Navigation and answer drafts for one pending request. */
|
||||
export interface QuestionDraftProgress {
|
||||
/** Current question index. */
|
||||
index: number
|
||||
/** One draft per question, in request order. */
|
||||
drafts: QuestionDraftAnswer[]
|
||||
}
|
||||
|
||||
interface QuestionDraftState {
|
||||
requestKey?: string
|
||||
progress: QuestionDraftProgress
|
||||
}
|
||||
|
||||
type QuestionDraftActions = {
|
||||
replace: (draft: QuestionDraftState, requestKey: string, progress: QuestionDraftProgress) => void
|
||||
clear: (draft: QuestionDraftState, requestKey: string) => void
|
||||
}
|
||||
|
||||
const emptyProgress = (): QuestionDraftProgress => ({ index: 0, drafts: [] })
|
||||
|
||||
/**
|
||||
* Declare the question composer's transient Session store.
|
||||
* @returns a non-persisted store handle whose instance is owned by the Slot registry.
|
||||
*/
|
||||
export function createQuestionDraftStore(): EngineStoreHandle<QuestionDraftState, QuestionDraftActions> {
|
||||
return defineStore({
|
||||
init: (): QuestionDraftState => ({ progress: emptyProgress() }),
|
||||
actions: {
|
||||
replace: (draft, requestKey, progress) => {
|
||||
draft.requestKey = requestKey
|
||||
draft.progress = progress
|
||||
},
|
||||
clear: (draft, requestKey) => {
|
||||
if (draft.requestKey !== requestKey) return
|
||||
delete draft.requestKey
|
||||
draft.progress = emptyProgress()
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { PendingQuestion } from './contract/slots.ts'
|
||||
import { createQuestionDraftStore } from './draft-store.ts'
|
||||
import { QuestionComposer } from './QuestionComposer.tsx'
|
||||
import { en, zh, type QuestionKey } from './locales.ts'
|
||||
|
||||
@@ -86,6 +87,7 @@ async function answerQuestion(
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-user-questions: dictionaries')
|
||||
const questionDraftStore = createQuestionDraftStore()
|
||||
const registerPendingInteraction = ctx.uiSession.registerPendingInteraction<PendingQuestion>(
|
||||
pending => pending.kind === 'plan-review' ? 2 : 1,
|
||||
)
|
||||
@@ -95,6 +97,7 @@ export function apply(ctx: ClientContext): void {
|
||||
select: ({ pendingInteraction }: ComposerChainProps): PendingQuestion | null =>
|
||||
pendingInteraction instanceof PendingQuestion ? pendingInteraction : null,
|
||||
locale: NS,
|
||||
store: questionDraftStore,
|
||||
},
|
||||
QuestionComposer,
|
||||
))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
|
||||
import { PendingQuestion } from '../src/client/contract/slots.ts'
|
||||
import { createQuestionDraftStore } from '../src/client/draft-store.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
const SESSION_ID = 'session-question' as SessionId
|
||||
@@ -122,6 +123,10 @@ describe('apply', () => {
|
||||
expect(entry.component).toBe(QuestionComposer)
|
||||
expect(entry.inject).toBeUndefined()
|
||||
expect(entry.locale).toBe('question')
|
||||
const store = entry.store as ReturnType<typeof createQuestionDraftStore>
|
||||
expect(store.create(SESSION_ID).getSnapshot()).toEqual({
|
||||
progress: { index: 0, drafts: [] },
|
||||
})
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
const select = entry.select as (
|
||||
owner: { pendingInteraction: PendingQuestion | undefined },
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
import { createQuestionDraftStore } from '../src/client/draft-store.ts'
|
||||
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
@@ -93,6 +94,8 @@ const inputState: InputState = {
|
||||
queue: [],
|
||||
}
|
||||
|
||||
const questionDraftStore = createQuestionDraftStore().create(SID)
|
||||
|
||||
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
|
||||
const kit: Omit<QuestionComposerProps, 'matched'> = {
|
||||
sessionId: SID,
|
||||
@@ -114,6 +117,8 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
|
||||
pruneImages: () => { throw new Error('unused') },
|
||||
submit: () => { throw new Error('unused') },
|
||||
},
|
||||
useStore: selector => selector(questionDraftStore.getSnapshot()),
|
||||
actions: questionDraftStore.actions,
|
||||
t: seatOver(zh, commonZh),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Question-composer Session store behavior. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createQuestionDraftStore, type QuestionDraftProgress } from '../src/client/draft-store.ts'
|
||||
|
||||
const FIRST: QuestionDraftProgress = {
|
||||
index: 1,
|
||||
drafts: [{ selected: ['Fast'], custom: '', skipped: false }],
|
||||
}
|
||||
|
||||
describe('createQuestionDraftStore', () => {
|
||||
it('keeps one request progress and ignores cleanup from an obsolete request', () => {
|
||||
const store = createQuestionDraftStore().create('session-one')
|
||||
|
||||
store.actions.replace('question:one', FIRST)
|
||||
expect(store.getSnapshot()).toEqual({ requestKey: 'question:one', progress: FIRST })
|
||||
|
||||
store.actions.clear('question:older')
|
||||
expect(store.getSnapshot()).toEqual({ requestKey: 'question:one', progress: FIRST })
|
||||
|
||||
store.actions.clear('question:one')
|
||||
expect(store.getSnapshot()).toEqual({ progress: { index: 0, drafts: [] } })
|
||||
})
|
||||
|
||||
it('replaces the previous request atomically instead of accumulating drafts', () => {
|
||||
const store = createQuestionDraftStore().create('session-one')
|
||||
const second: QuestionDraftProgress = {
|
||||
index: 0,
|
||||
drafts: [{ selected: [], custom: 'Careful', skipped: false }],
|
||||
}
|
||||
|
||||
store.actions.replace('question:one', FIRST)
|
||||
store.actions.replace('question:two', second)
|
||||
|
||||
expect(store.getSnapshot()).toEqual({ requestKey: 'question:two', progress: second })
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
|
||||
import { createQuestionDraftStore } from '../src/client/draft-store.ts'
|
||||
import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
@@ -91,10 +93,10 @@ const inputState: InputState = {
|
||||
queue: [],
|
||||
}
|
||||
|
||||
/** Framework standard-kit stubs: the composer consumes only the locale seat;
|
||||
/** Framework standard-kit stubs: the composer consumes the locale and draft-store seats;
|
||||
* the composed props type mandates delivery of the rest (framework hooks are
|
||||
* plain stubs per the client testing discipline). */
|
||||
const kit: Omit<QuestionComposerProps, 'matched'> = {
|
||||
const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> = {
|
||||
session: undefined,
|
||||
sessionId: SID,
|
||||
pendingInteraction: undefined,
|
||||
@@ -118,6 +120,18 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
|
||||
t: seatOver(zh, commonZh),
|
||||
}
|
||||
|
||||
let kit: Omit<QuestionComposerProps, 'matched'>
|
||||
|
||||
beforeEach(() => {
|
||||
const instance = createQuestionDraftStore().create(SID)
|
||||
const useStore: QuestionComposerProps['useStore'] = selector => useSyncExternalStore(
|
||||
listener => instance.subscribe(listener),
|
||||
() => selector(instance.getSnapshot()),
|
||||
() => selector(instance.getSnapshot()),
|
||||
)
|
||||
kit = { ...kitBase, useStore, actions: instance.actions }
|
||||
})
|
||||
|
||||
const QUESTIONS: PendingQuestion['questions'] = [
|
||||
{
|
||||
id: 'profile', header: '偏好', question: '选择候选人类型',
|
||||
@@ -363,13 +377,21 @@ describe('QuestionComposer', () => {
|
||||
expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps drafts when the same pending request rerenders', () => {
|
||||
it('restores the current page and drafts after the strict Session entry remounts', () => {
|
||||
const pending = wait()
|
||||
const view = render(<QuestionComposer matched={pending.carrier} {...kit} />)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
|
||||
const custom = screen.getByPlaceholderText('输入你的答案')
|
||||
fireEvent.change(custom, { target: { value: '保留这段草稿' } })
|
||||
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||
view.rerender(<QuestionComposer matched={pending.carrier} {...kit} />)
|
||||
|
||||
view.unmount()
|
||||
render(<QuestionComposer matched={pending.carrier} {...kit} />)
|
||||
|
||||
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||
expect(screen.getByPlaceholderText<HTMLTextAreaElement>('输入你的答案').value).toBe('保留这段草稿')
|
||||
fireEvent.click(screen.getByLabelText('上一题'))
|
||||
expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
|
||||
@@ -1152,4 +1152,5 @@ export class SessionStore extends Service {
|
||||
|
||||
}
|
||||
|
||||
export { decodeSeqRanges, encodeSeqRanges } from './seq-ranges.ts'
|
||||
export default SessionStore
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Lossless range encoding for JSONL `sourceEventSeqs` arrays. */
|
||||
|
||||
/** A stored source sequence or inclusive consecutive range. */
|
||||
export type EncodedSeq = number | [number, number]
|
||||
|
||||
function isStrictlyIncreasing(values: readonly number[]): boolean {
|
||||
return values.every((value, index) => index === 0 || value > (values[index - 1] as number))
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace profitable consecutive runs with inclusive pairs.
|
||||
* @param values - validated in-memory source sequences.
|
||||
* @returns a lossless JSON storage form.
|
||||
*/
|
||||
export function encodeSeqRanges(values: readonly number[]): EncodedSeq[] {
|
||||
if (!isStrictlyIncreasing(values)) return [...values]
|
||||
const encoded: EncodedSeq[] = []
|
||||
for (let start = 0; start < values.length;) {
|
||||
let end = start
|
||||
while (end + 1 < values.length && values[end + 1] === (values[end] as number) + 1) end += 1
|
||||
if (end - start >= 2) encoded.push([values[start] as number, values[end] as number])
|
||||
else for (let index = start; index <= end; index += 1) encoded.push(values[index] as number)
|
||||
start = end + 1
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a JSON storage-form source sequence array.
|
||||
* @param value - parsed storage value.
|
||||
* @param maxEntries - largest list permitted by the owning event.
|
||||
* @returns the in-memory source sequences.
|
||||
*/
|
||||
export function decodeSeqRanges(value: unknown, maxEntries = Number.MAX_SAFE_INTEGER): number[] {
|
||||
if (!Array.isArray(value)) throw new TypeError('sourceEventSeqs must be an array')
|
||||
const decoded: number[] = []
|
||||
let hasRange = false
|
||||
for (const entry of value) {
|
||||
if (typeof entry === 'number') {
|
||||
assertSeq(entry)
|
||||
if (decoded.length >= maxEntries) throw new TypeError('sourceEventSeqs exceeds its event sequence')
|
||||
decoded.push(entry)
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(entry) || entry.length !== 2) {
|
||||
throw new TypeError('sourceEventSeqs range entries must be [start, end] pairs')
|
||||
}
|
||||
const start: unknown = entry[0]
|
||||
const end: unknown = entry[1]
|
||||
assertSeq(start)
|
||||
assertSeq(end)
|
||||
if (end < start) throw new TypeError('sourceEventSeqs ranges require start <= end')
|
||||
const length = end - start + 1
|
||||
if (length > maxEntries - decoded.length) {
|
||||
throw new TypeError('sourceEventSeqs range exceeds its event sequence')
|
||||
}
|
||||
for (let seq = start; seq <= end; seq += 1) decoded.push(seq)
|
||||
hasRange = true
|
||||
}
|
||||
if (hasRange && !isStrictlyIncreasing(decoded)) {
|
||||
throw new TypeError('sourceEventSeqs ranges must be strictly increasing')
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
function assertSeq(value: unknown): asserts value is number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new TypeError('sourceEventSeqs must contain non-negative safe integers')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeSeqRanges, encodeSeqRanges } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('sourceEventSeqs ranges', () => {
|
||||
it.each([
|
||||
[],
|
||||
[5],
|
||||
[10, 11, 12, 13, 14],
|
||||
[16, 17, 100, 200, 201, 202, 203],
|
||||
[3, 2],
|
||||
[Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2],
|
||||
].map(values => [values]))('round-trips %j', (values) => {
|
||||
expect(decodeSeqRanges(encodeSeqRanges(values))).toEqual(values)
|
||||
})
|
||||
|
||||
it('encodes only profitable increasing runs', () => {
|
||||
expect(encodeSeqRanges([1, 3, 4, 5, 7])).toEqual([1, [3, 5], 7])
|
||||
expect(encodeSeqRanges([1, 3, 4, 7])).toEqual([1, 3, 4, 7])
|
||||
expect(encodeSeqRanges([3, 2])).toEqual([3, 2])
|
||||
})
|
||||
|
||||
it('does not impose a persistence-only provenance length limit', () => {
|
||||
const values = Array.from({ length: 1_000_001 }, (_, index) => index)
|
||||
expect(encodeSeqRanges(values)).toEqual([[0, 1_000_000]])
|
||||
})
|
||||
|
||||
it('rejects malformed or impossible expansions', () => {
|
||||
expect(() => decodeSeqRanges('nope')).toThrow(/must be an array/)
|
||||
expect(() => decodeSeqRanges([-1])).toThrow(/non-negative safe integers/)
|
||||
expect(() => decodeSeqRanges([[1]])).toThrow(/\[start, end\] pairs/)
|
||||
expect(() => decodeSeqRanges([[4, 2]])).toThrow(/start <= end/)
|
||||
expect(() => decodeSeqRanges([[2, 5], [4, 7]])).toThrow(/strictly increasing/)
|
||||
expect(() => decodeSeqRanges([0], 0)).toThrow(/exceeds its event sequence/)
|
||||
expect(() => decodeSeqRanges([[0, 10]], 10)).toThrow(/exceeds its event sequence/)
|
||||
})
|
||||
})
|
||||
@@ -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/experimental/webworker-packer/README.md
|
||||
README.md: 8464c2a6e442c6f6a88d6cf87ddd97de069ea37f
|
||||
README.zh.md: c5a0c0e10c273dcd1477a906a7c2a14407034c2c
|
||||
README.md: fd4f9793b17ad66ae4c5f1038ce027b2efe43abe
|
||||
README.zh.md: 85e53445eb149fe55717eec27028f8285823af05
|
||||
|
||||
@@ -27,7 +27,7 @@ The pack is a three-layer standard stack:
|
||||
|
||||
1. **Roster** — the composed profile's plugin rows (standard YAML parse under Include's dialect, `!!js` intact), plus the rows of every config tree the CLI declares in its `package.json` `dsh.configTrees` (agent presets), materialized as a Node-style dependency closure. External peer edges never bind the worker; workspace peers stay on the chain.
|
||||
2. **Publish view** — each workspace or vendored package contributes its built npm slice (`files` through picomatch) without source or workspace `dist/`. External packages retain published JavaScript under both `src/` and `dist/` because their `main` or `exports` may point there; only generic test, map, declaration, and archive exclusions apply.
|
||||
3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time.
|
||||
3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. The transform reports statically named imports, re-exports, and dynamic imports; calls through `require`; and module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named import from `node:module` or `module`, including an import alias. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time.
|
||||
|
||||
`repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `native/landlock-run/packages/`, and `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The native scan makes the Landlock entry package an ordinary published-view dependency while its executable remains a Worker platform implementation. The CLI is `dsh-pack-vfs-image --out <file> [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build.
|
||||
|
||||
@@ -49,6 +49,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **The rule tables are judgement calls** (`rules.ts`: exclude globs, page-asset patterns, entry seeds) pinned by `tests/`; a new asset class the worker must reach needs a table row, not a scanner change.
|
||||
- **Reachability infers only exact request forms** — computed `import` and `require` arguments, stored `createRequire` results, CommonJS-obtained `createRequire`, and bases other than `import.meta.url` resolve only at runtime and fail loud if the target was otherwise pruned; a target reachable only through those forms needs an explicit image entry seed.
|
||||
- **Vendored package sources (`src/*.ts`) are excluded** — nothing resolves them at runtime; a future in-worker source-inspection feature would need a dedicated include rule.
|
||||
- **The packer assumes built `lib/` artifacts are current**: it never compiles, so a stale workspace build packs stale bytes. Run the repository build first.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为
|
||||
|
||||
1. **Roster**——合成 profile 的插件行(标准 YAML 解析、Include 方言、`!!js` 原样保留),加上 CLI 在 `package.json` `dsh.configTrees` 里声明的每棵配置树(agent presets)的行,按 Node 式依赖闭包物化。外部包的 peer 边不追,workspace peer 保留在链上。
|
||||
2. **发布视图**——每个 workspace 或 vendored 包贡献其构建后的 npm 切片(`files` 走 picomatch),不带源码和 workspace `dist/`。外部包的 `main` 或 `exports` 可能指向 `src/` 或 `dist/`,因此两处发布 JavaScript 都会保留,只应用通用的测试、map、声明与归档排除规则。
|
||||
3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。
|
||||
3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。Transform 会报告具名静态 import、re-export 与动态 import、经 `require` 发起的调用,以及通过 `node:module` 或 `module` 具名导入(含导入别名)在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。
|
||||
|
||||
`repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`native/landlock-run/packages/` 与 `apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。Native 扫描使 Landlock 入口包成为普通发布视图依赖,其可执行文件仍由 Worker 平台实现。CLI 为 `dsh-pack-vfs-image --out <file> [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。
|
||||
|
||||
@@ -49,6 +49,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **规则表是判断题**(`rules.ts`:exclude glob、页面资产模式、入口种子),由 `tests/` 钉住;worker 需要触达的新资产类别应加表行,而不是改扫描器。
|
||||
- **可达性只推断精确请求形式**——计算得到的 `import` 与 `require` 参数、保存下来的 `createRequire` 结果、经 CommonJS 获取的 `createRequire`,以及基准不是 `import.meta.url` 的调用只在运行时解析;若目标已被裁掉就会立即失败。只能通过这些形式触达的目标需要显式镜像入口种子。
|
||||
- **vendored 包源码(`src/*.ts`)被排除**——运行时无人解析它们;未来若有 worker 内源码巡检功能需要专门的 include 规则。
|
||||
- **打包器假定构建产物 `lib/` 是新鲜的**:它从不编译,工作区构建过期就打包过期字节。先跑仓库构建。
|
||||
|
||||
|
||||
@@ -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/experimental/webworker-runtime/README.md
|
||||
README.md: 9d00ec5b56c958149eceef34de16c36741ce0565
|
||||
README.zh.md: 04039d09ed60d0cf25d851dcd9767fd80b9a7221
|
||||
README.md: eaf467b8e4475908d5534113553c51182e30b7f2
|
||||
README.zh.md: 879f9a5d2d949296886ed6af1e6bf40bd42540d5
|
||||
|
||||
@@ -26,7 +26,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated
|
||||
Three artifacts from one tsdown pipeline:
|
||||
|
||||
- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`.
|
||||
- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
|
||||
- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
|
||||
- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process.
|
||||
- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ kind: "package-library"
|
||||
一条 tsdown 管线出三个产物:
|
||||
|
||||
- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。
|
||||
- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
|
||||
- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
|
||||
- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。
|
||||
- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ class Transformer {
|
||||
private moduleSyntax = false
|
||||
private readonly moduleRequests = new Set<string>()
|
||||
private readonly metaResolveRequests = new Set<string>()
|
||||
private readonly createRequireBindings = new Set<string>()
|
||||
|
||||
constructor(source: string, private readonly path: string) {
|
||||
// A `#!` line is only legal at offset zero, and the prologue takes that spot;
|
||||
@@ -154,7 +155,8 @@ class Transformer {
|
||||
if (Array.isArray(node.attributes) && node.attributes.length > 0) {
|
||||
this.fail('import attributes are not supported', node.start)
|
||||
}
|
||||
const request = `require(${this.literal(node.source as Node)})`
|
||||
const source = node.source as Node
|
||||
const request = `require(${this.literal(source)})`
|
||||
const specifiers = node.specifiers as Node[]
|
||||
if (specifiers.length === 0) {
|
||||
this.replace(node.start, node.end, `${request};`)
|
||||
@@ -313,7 +315,12 @@ class Transformer {
|
||||
|
||||
// --- traversal ------------------------------------------------------------
|
||||
|
||||
private visit(node: unknown, context: { asyncGenerator: boolean; functionDepth: number; statement?: Node }): void {
|
||||
private visit(node: unknown, context: {
|
||||
asyncGenerator: boolean
|
||||
functionDepth: number
|
||||
moduleScope: boolean
|
||||
statement?: Node
|
||||
}): void {
|
||||
if (node === null || typeof node !== 'object') return
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) this.visit(child, context)
|
||||
@@ -338,11 +345,12 @@ class Transformer {
|
||||
break
|
||||
}
|
||||
case 'CallExpression': {
|
||||
// CommonJS bodies pass through untransformed, but their literal
|
||||
// `require()` calls are module requests all the same.
|
||||
// CommonJS bodies pass through untransformed, but literal calls through
|
||||
// the wrapper's `require` remain module requests. The ESM case accepts
|
||||
// only a direct module-scope createRequire call with the importer URL.
|
||||
const callee = record.callee as Node
|
||||
const callArguments = record.arguments as Node[]
|
||||
if (callee.type === 'Identifier' && callee.name === 'require' && callArguments.length === 1
|
||||
if (this.isRequireCall(callee, context.moduleScope) && callArguments.length === 1
|
||||
&& typeof callArguments[0]?.value === 'string') {
|
||||
this.moduleRequests.add(callArguments[0].value)
|
||||
}
|
||||
@@ -382,6 +390,7 @@ class Transformer {
|
||||
if (context.functionDepth === 0) this.fail('a top-level for-await loop cannot run as CommonJS', record.start)
|
||||
this.forAwait(record)
|
||||
}
|
||||
next = { ...next, moduleScope: false }
|
||||
break
|
||||
case 'LabeledStatement': {
|
||||
const body = record.body as Node
|
||||
@@ -399,8 +408,17 @@ class Transformer {
|
||||
next = {
|
||||
asyncGenerator: record.async === true && record.generator === true,
|
||||
functionDepth: context.functionDepth + 1,
|
||||
moduleScope: false,
|
||||
}
|
||||
break
|
||||
case 'BlockStatement':
|
||||
case 'CatchClause':
|
||||
case 'ClassBody':
|
||||
case 'ForStatement':
|
||||
case 'ForInStatement':
|
||||
case 'SwitchStatement':
|
||||
next = { ...next, moduleScope: false }
|
||||
break
|
||||
default: break
|
||||
}
|
||||
if (record.type === 'ExpressionStatement') next = { ...next, statement: record }
|
||||
@@ -410,6 +428,40 @@ class Transformer {
|
||||
}
|
||||
}
|
||||
|
||||
private isCreateRequireCall(node: Node): boolean {
|
||||
if (node.type !== 'CallExpression') return false
|
||||
const callee = node.callee as Node
|
||||
const args = node.arguments as Node[]
|
||||
if (callee.type !== 'Identifier' || !this.createRequireBindings.has(nameOf(callee)) || args.length !== 1) {
|
||||
return false
|
||||
}
|
||||
const base = args[0] as Node
|
||||
if (base.type !== 'MemberExpression' || base.computed === true) return false
|
||||
const object = base.object as Node
|
||||
const property = base.property as Node
|
||||
return object.type === 'MetaProperty'
|
||||
&& (object.meta as Node).name === 'import'
|
||||
&& property.type === 'Identifier'
|
||||
&& property.name === 'url'
|
||||
}
|
||||
|
||||
private isRequireCall(callee: Node, moduleScope: boolean): boolean {
|
||||
return (callee.type === 'Identifier' && callee.name === 'require')
|
||||
|| (moduleScope && this.isCreateRequireCall(callee))
|
||||
}
|
||||
|
||||
private indexCreateRequireImports(program: Node): void {
|
||||
for (const statement of program.body as Node[]) {
|
||||
if (statement.type !== 'ImportDeclaration') continue
|
||||
const source = statement.source as Node
|
||||
if (source.value !== 'node:module' && source.value !== 'module') continue
|
||||
for (const specifier of statement.specifiers as Node[]) {
|
||||
if (specifier.type !== 'ImportSpecifier' || nameOf(specifier.imported as Node) !== 'createRequire') continue
|
||||
this.createRequireBindings.add(nameOf(specifier.local as Node))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run(): string {
|
||||
// Transforming a lowered body again would nest the protocol inside itself:
|
||||
// it still runs, only slower and unreadable, so a mis-wired manifest must
|
||||
@@ -427,7 +479,8 @@ class Transformer {
|
||||
} catch (reason) {
|
||||
this.fail(`parse failed: ${(reason as Error).message}`, 0)
|
||||
}
|
||||
this.visit(program, { asyncGenerator: false, functionDepth: 0 })
|
||||
this.indexCreateRequireImports(program)
|
||||
this.visit(program, { asyncGenerator: false, functionDepth: 0, moduleScope: true })
|
||||
if (this.edits.length === 0 && !this.moduleSyntax) return this.source
|
||||
|
||||
const prologue: string[] = []
|
||||
@@ -539,8 +592,9 @@ export interface LoweredModule {
|
||||
readonly lowered: boolean
|
||||
/**
|
||||
* Static module requests the body makes: import and re-export sources,
|
||||
* literal dynamic imports, and literal `require()` calls. Computed requests
|
||||
* are absent — they resolve (and fail loud) at runtime only.
|
||||
* literal dynamic imports and calls through `require`, plus module-scope
|
||||
* direct literal calls through an imported `createRequire(import.meta.url)`.
|
||||
* Computed and rebased requests resolve (and fail loud) at runtime only.
|
||||
*/
|
||||
readonly moduleRequests: readonly string[]
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* The `process` global the worker needs before any VFS module runs. Cordis
|
||||
* reads `process.env` and `process.versions.node` while the Loader is
|
||||
* constructed, and `cordis.yml` keeps its `!!js process.*` expressions, so the
|
||||
* configuration bytes stay identical to the Node deployment.
|
||||
* configuration bytes stay identical to the Node deployment. Third-party Node
|
||||
* packages use the presence of `process.title` to avoid browser-only globals.
|
||||
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/node/globals/process
|
||||
*/
|
||||
import { requireActiveModuleLoader } from '../../module-system/module-loader.ts'
|
||||
@@ -23,6 +24,8 @@ export interface ProcessShim {
|
||||
readonly env: Record<string, string>
|
||||
readonly argv: string[]
|
||||
readonly execArgv: string[]
|
||||
/** Node process identity used by dependencies for environment detection. */
|
||||
readonly title: string
|
||||
/**
|
||||
* Node 22 `process.getBuiltinModule`: the worker's module proxy for a
|
||||
* builtin id (`fs`, `node:fs`), or undefined for anything else — it never
|
||||
@@ -86,6 +89,7 @@ export function installProcessGlobal(options: ProcessShimOptions): ProcessShim {
|
||||
env: { ...options.env },
|
||||
argv: [...(options.argv ?? ['node', 'dsh-webworker'])],
|
||||
execArgv: [],
|
||||
title: 'dsh-webworker',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
pid: 1,
|
||||
|
||||
@@ -144,6 +144,35 @@ check(
|
||||
check('lowered mirrors code !== source', cjsAwait.lowered, cjsAwait.code !== 'module.exports = async () => { await 1 }\n')
|
||||
}
|
||||
|
||||
{
|
||||
const direct = lowerModuleSource({
|
||||
filename: 'node_modules/p/direct.js',
|
||||
source: "import { createRequire } from 'node:module'\ncreateRequire(import.meta.url)('external-package')\n",
|
||||
})
|
||||
check('literal createRequire call is a module request', direct.moduleRequests, ['node:module', 'external-package'])
|
||||
|
||||
const aliased = lowerModuleSource({
|
||||
filename: 'node_modules/p/aliased.js',
|
||||
source: "makeRequire(import.meta.url)('aliased-package')\nimport { createRequire as makeRequire } from 'node:module'\n",
|
||||
})
|
||||
check('aliased createRequire import is indexed before traversal', aliased.moduleRequests, ['aliased-package', 'node:module'])
|
||||
|
||||
const runtimeOnly = lowerModuleSource({
|
||||
filename: 'node_modules/p/runtime-only.js',
|
||||
source: [
|
||||
"import { createRequire } from 'node:module'",
|
||||
'const localRequire = createRequire(import.meta.url)',
|
||||
"localRequire('stored')",
|
||||
"createRequire(new URL('./other.js', import.meta.url))('rebased')",
|
||||
"{ const createRequire = () => () => undefined; createRequire(import.meta.url)('block-shadowed') }",
|
||||
"function load(createRequire) { createRequire(import.meta.url)('parameter-shadowed') }",
|
||||
"for (const createRequire of []) createRequire(import.meta.url)('for-of-shadowed')",
|
||||
"switch (0) { case 0: const createRequire = () => () => undefined; createRequire(import.meta.url)('switch-shadowed') }",
|
||||
].join('\n'),
|
||||
})
|
||||
check('stored, rebased, and shadowed createRequire calls stay runtime-only', runtimeOnly.moduleRequests, ['node:module'])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Import forms.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('process shim', () => {
|
||||
const shim = installProcessGlobal({ cwd: '/dsh', env: { DSH_HOME: '/dsh/home' } })
|
||||
expect(shim.cwd()).toBe('/dsh')
|
||||
expect(shim.env.DSH_HOME).toBe('/dsh/home')
|
||||
expect(shim.title).toBe('dsh-webworker')
|
||||
// "0.0.0" keeps the vendored Loader off Node internals so the worker owns
|
||||
// the module seam.
|
||||
expect(shim.versions.node).toBe('0.0.0')
|
||||
|
||||
@@ -1558,6 +1558,86 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.header\', () => ctx.slots.register(\n { name: \'settings.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-settings/src/client/contract/slots.ts:30',
|
||||
},
|
||||
{
|
||||
key: 'settings.models.footer',
|
||||
kind: 'list',
|
||||
scope: 'root',
|
||||
summary: 'Ordered extension area after the provider rows and the add controls.',
|
||||
doc: 'Ordered extension area after the provider rows and the add controls.\nWithout a registrant the area renders nothing.',
|
||||
registerOptions: [
|
||||
{
|
||||
name: 'id',
|
||||
requirement: 'required',
|
||||
type: 'string',
|
||||
doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
requirement: 'optional',
|
||||
type: 'number',
|
||||
doc: 'Position among the entries, ascending (default 0).',
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
requirement: 'optional',
|
||||
type: 'string | (() => string)',
|
||||
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** Owner share of the footer area (the section supplies nothing). */\nexport interface ModelsFooterOwnerProps {\n /** Marker field: footer owner props are intentionally empty. */\n children?: never\n}',
|
||||
],
|
||||
ownerPropsReferences: [],
|
||||
standardProps: [
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'useSessions: UseSessions',
|
||||
'useSessionPendingInteraction: UseSessionPendingInteraction',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
],
|
||||
keyDomain: '',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'settings.section\' (client-ui-settings-models), so it exists while that entry is mounted',
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.models.footer\', () => ctx.slots.register(\n { name: \'settings.models.footer\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:38',
|
||||
},
|
||||
{
|
||||
key: 'settings.models.provider-card',
|
||||
kind: 'keyed',
|
||||
scope: 'root',
|
||||
summary: 'One provider card\'s adapter extension area, dispatched with `entryKey = settingsNs` on every card that renders a directory row: a saved row\'s card (its first-run setup posture included) and the add-provider draft card.',
|
||||
doc: 'One provider card\'s adapter extension area, dispatched with\n`entryKey = settingsNs` on every card that renders a directory row: a\nsaved row\'s card (its first-run setup posture included) and the\nadd-provider draft card. The hand-declared draft card has no directory\nrow yet, so it dispatches nothing until saved. Without a registrant the\narea renders nothing.',
|
||||
registerOptions: [
|
||||
{
|
||||
name: 'key',
|
||||
requirement: 'required',
|
||||
type: 'string',
|
||||
doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.',
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** Owner share of one provider-card extension occurrence. */\nexport interface ProviderCardExtrasOwnerProps {\n /** The card\'s directory row (route id, display name, settings address, live state). */\n provider: ConfigurableProviderView\n /** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */\n configured: boolean\n /** Whether the row\'s referenced api-key credential is confirmed configured (the page\'s credential join). */\n keyConfigured: boolean\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'ConfigurableProviderView',
|
||||
],
|
||||
standardProps: [
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'useSessions: UseSessions',
|
||||
'useSessionPendingInteraction: UseSessionPendingInteraction',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
],
|
||||
keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'settings.section\' (client-ui-settings-models), so it exists while that entry is mounted',
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.models.provider-card\', () => ctx.slots.register(\n { name: \'settings.models.provider-card\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:33',
|
||||
},
|
||||
{
|
||||
key: 'settings.onboarding',
|
||||
kind: 'list',
|
||||
|
||||
@@ -3434,7 +3434,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ClientArtifactBaseline',
|
||||
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n readonly mapMtimeMs: number | null;\n readonly mapSize: number | null;\n}',
|
||||
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CodeBindingErrorClass',
|
||||
|
||||
@@ -37,6 +37,30 @@ export function recordKeyFor(providerId: string): CredentialKey {
|
||||
return credentialKey(RECORD_SCOPE, providerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON image of one grant payload: plain objects lose their
|
||||
* explicitly-undefined members and array entries JSON cannot hold become
|
||||
* null, exactly as `JSON.stringify` would render them. pi-ai credentials
|
||||
* idiomatically carry optional members as explicit `undefined` (a github.com
|
||||
* Copilot grant holds `enterpriseUrl: undefined`), which the credential
|
||||
* store's strict validator refuses as unrepresentable. Everything else —
|
||||
* non-finite numbers and foreign prototypes included — passes through
|
||||
* untouched, so a genuinely unstorable value still fails loud at the store.
|
||||
* @param value - the value to render.
|
||||
* @returns the value's JSON image.
|
||||
*/
|
||||
function jsonImage(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(entry => entry === undefined ? null : jsonImage(entry))
|
||||
if (typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype) {
|
||||
const image: Record<string, unknown> = {}
|
||||
for (const [key, member] of Object.entries(value)) {
|
||||
if (member !== undefined) image[key] = jsonImage(member)
|
||||
}
|
||||
return image
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a stored record into the credential pi-ai expects.
|
||||
*
|
||||
@@ -72,7 +96,7 @@ function toRecord(credential: Credential): CredentialRecord {
|
||||
...credential.env === undefined ? {} : { env: { ...credential.env } },
|
||||
}
|
||||
}
|
||||
return { kind: 'grant', payload: credential }
|
||||
return { kind: 'grant', payload: jsonImage(credential) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,47 @@ describe('pi-ai credential store over harness records', () => {
|
||||
await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ kind: 'grant', payload: granted })
|
||||
})
|
||||
|
||||
it('stores the JSON image of a grant, dropping explicitly-undefined members', async () => {
|
||||
const ctx = await stored()
|
||||
const store = credentialStoreFrom(ctx)
|
||||
// The github.com Copilot shape: pi-ai sets optional members to explicit
|
||||
// undefined, which the strict record validator refuses verbatim.
|
||||
const granted = {
|
||||
type: 'oauth' as const,
|
||||
access: 'at',
|
||||
refresh: 'rt',
|
||||
expires: 42,
|
||||
enterpriseUrl: undefined,
|
||||
nested: { keep: 'x', drop: undefined },
|
||||
list: ['a', undefined, 'b'],
|
||||
}
|
||||
|
||||
await store.modify('github-copilot', () => Promise.resolve(granted))
|
||||
|
||||
await expect(ctx.credentials.readRecord(recordKeyFor('github-copilot'))).resolves.toEqual({
|
||||
kind: 'grant',
|
||||
payload: {
|
||||
type: 'oauth',
|
||||
access: 'at',
|
||||
refresh: 'rt',
|
||||
expires: 42,
|
||||
nested: { keep: 'x' },
|
||||
list: ['a', null, 'b'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('passes a genuinely unstorable grant value through to the store\'s loud refusal', async () => {
|
||||
const ctx = await stored()
|
||||
const store = credentialStoreFrom(ctx)
|
||||
// A foreign-prototype member is not the undefined idiom: the image leaves
|
||||
// it untouched and the record validator still refuses the write.
|
||||
const granted = { type: 'oauth' as const, access: 'at', refresh: 'rt', expires: 42, issued: new Date(0) }
|
||||
|
||||
await expect(store.modify('github-copilot', () => Promise.resolve(granted)))
|
||||
.rejects.toThrow(/JSON cannot represent/)
|
||||
})
|
||||
|
||||
it('shows the mutation the current credential and leaves it alone when declined', async () => {
|
||||
const store = credentialStoreFrom(await stored())
|
||||
await store.modify('openai-codex', () =>
|
||||
|
||||
@@ -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/session/session-persistence-jsonl/README.md
|
||||
README.md: cd0e9fefaed6260f890efd07160dbc264b915c9a
|
||||
README.zh.md: 8be8571bc7424621d4ecb940a8e651c8b5f73e6e
|
||||
README.md: 69fb2901d783c327878cd37570aec730a3ca0841
|
||||
README.zh.md: 188982028374d4ca11a5a658acabce5fe5df9930
|
||||
|
||||
@@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
### On-disk layout
|
||||
|
||||
Each session gets a session-owned directory under a readable project directory; the first logical line of the log is the immutable `SessionHeader`, followed by one storage record per logical event (or one packed chunk row per eligible run):
|
||||
Each session gets a session-owned directory under a readable project directory; the first logical line of the log is the immutable `SessionHeader`, followed by one storage record per logical event (or one packed chunk row per eligible run). Storage records use the lossless provenance representation described below:
|
||||
|
||||
```text
|
||||
<root>/
|
||||
@@ -90,7 +90,7 @@ The backend is a thin storage layer over the shared [PersistenceCoordinator](../
|
||||
|
||||
### Physical encoding
|
||||
|
||||
The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, then one checksummed frame per durable append batch, using Node's built-in Zstandard API at its default compression level (no level knob). Listing reads and validates only the header frame. A root belongs to one encoding: startup discovery and targeted lookup reject the opposite suffix, and there is no format or compression migration, mixed-root fallback, or dual write. When `packChunks` is enabled, an eligible run of ≥3 consecutive same-block `assistant/chunk` delta events becomes one packed row (`text-chunks`/`reasoning-chunks`/`tool-call-chunks`) whose `seq0`/`time0` and per-member `dt` gaps reconstruct every member exactly; the lossless codec lives in `dsh-session` and reading is layout-blind, so packed, unpacked, and mixed files load identically.
|
||||
The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, then one checksummed frame per durable append batch, using Node's built-in Zstandard API at its default compression level (no level knob). `sourceEventSeqs` uses a lossless storage representation: consecutive runs of at least three sequence numbers become `[start, end]` pairs, any other list stays verbatim, and reading expands the exact in-memory array. Listing reads and validates only the header frame. `compression: 'none'` keeps the same storage-form logical lines without frame compression. A root belongs to one encoding: startup discovery and targeted lookup reject the opposite suffix, and there is no format or compression migration, mixed-root fallback, or dual write. When `packChunks` is enabled, an eligible run of ≥3 consecutive same-block `assistant/chunk` delta events becomes one packed row (`text-chunks`/`reasoning-chunks`/`tool-call-chunks`) whose `seq0`/`time0` and per-member `dt` gaps reconstruct every member exactly; the lossless codec lives in `dsh-session` and reading is layout-blind, so packed, unpacked, and mixed files load identically.
|
||||
|
||||
### Source map
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ kind: "package-reference"
|
||||
|
||||
### 磁盘布局
|
||||
|
||||
每个会话在可读项目目录下获得一个会话自有目录;日志第一个逻辑行是不可变 `SessionHeader`,之后每个逻辑事件一条存储记录(或每个符合条件的连续段一条打包分片行):
|
||||
每个会话在可读项目目录下获得一个会话自有目录;日志第一个逻辑行是不可变 `SessionHeader`,之后每个逻辑事件一条存储记录(或每个符合条件的连续段一条打包分片行)。存储记录使用下文所述的无损来源序列表示:
|
||||
|
||||
```text
|
||||
<root>/
|
||||
@@ -90,7 +90,7 @@ kind: "package-reference"
|
||||
|
||||
### 物理编码
|
||||
|
||||
默认产物是独立 [Zstandard 帧](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带校验和帧,后跟每个持久 append 批次一个带校验和帧,使用 Node 内置 Zstandard API 的默认压缩级别(无级别开关)。列表只读取并验证 header 帧。一个根只属于一种编码:启动发现与定向查找会拒绝相反后缀,且不提供格式或压缩迁移、混合根回退或双写。启用 `packChunks` 时,符合条件的 ≥3 个连续同 block `assistant/chunk` delta 事件连续段会变成一行打包行(`text-chunks`/`reasoning-chunks`/`tool-call-chunks`),其 `seq0`/`time0` 与各成员的 `dt` 间隔精确重建每个成员;无损 codec 位于 `dsh-session`,读取与布局无关,因此打包、非打包与混合文件加载结果一致。
|
||||
默认产物是独立 [Zstandard 帧](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带校验和帧,后跟每个持久 append 批次一个带校验和帧,使用 Node 内置 Zstandard API 的默认压缩级别(无级别开关)。`sourceEventSeqs` 使用无损存储形式:至少包含三个序列号的连续段会变成 `[start, end]` 区间对,其他列表原样保留;读取时会展开回精确的内存数组。列表只读取并验证 header 帧。`compression: 'none'` 保留相同的存储形式逻辑行,但不使用帧压缩。一个根只属于一种编码:启动发现与定向查找会拒绝相反后缀,且不提供格式或压缩迁移、混合根回退或双写。启用 `packChunks` 时,符合条件的 ≥3 个连续同 block `assistant/chunk` delta 事件连续段会变成一行打包行(`text-chunks`/`reasoning-chunks`/`tool-call-chunks`),其 `seq0`/`time0` 与各成员的 `dt` 间隔精确重建每个成员;无损 codec 位于 `dsh-session`,读取与布局无关,因此打包、非打包与混合文件加载结果一致。
|
||||
|
||||
### 源码地图
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user