diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 5b400d15a2..aaa8fdbedc 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -18,14 +18,14 @@ en: 5fdde26e9e8c6108 zh: 629559d733c1f0c5 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/plugin-resolution-the-vfs-holds-a-real-package-tree-the-closure-manifest-is-the-deploy-root: - en: c35ea4b858448b49 - zh: 1f8ff0a9f82ca881 + en: 985e0eb63f3d1fd6 + zh: 337cc44fadc6227e /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts: - en: d3bbd24ead02af4f - zh: 64623d88bee4314b + en: 921085cb9d894688 + zh: 12d99fe153d36738 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development: - en: 9bd60549d4a52ec0 - zh: e2343b7210212ee0 + en: 02e6b36f9f92c83a + zh: edd8b2db7a787483 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/naming-lineage: en: 31a5aecb8bf828bb zh: 2dffb6fd2e0f8194 @@ -33,8 +33,8 @@ en: 0426f598f3f44e64 zh: fc66728e17f3bcfb /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/testing: - en: b3c9820f4dc7c6c8 - zh: 3b5768ef1770bfcb + en: 8e7f46bbb595afa9 + zh: 4641894ed285d717 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/alternatives-considered: en: 50d965ca81a401ba zh: 0c6296245b97b9ac diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 27d37ed541..60228735f5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -44,19 +44,19 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers. The optional resource service provides MCP Resources; MCP Prompts remain unsupported. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. -The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in `-office/node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. Missing required packages and missing installed package manifests fail the copy; absent optionals preserve the kit's engine selection. +The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in the explicitly downloaded sidecar’s `node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. The build locks the selected engine and dependency locations to exact npm tarballs; downloads verify their integrity before publication. ### Build pipeline and artifacts -[`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.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → 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 → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering 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--` under `dist-exe/` and copy it with its target-specific Office directory into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. 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`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; 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. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. +[`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.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → 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 → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering 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--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. 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`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; 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. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / 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, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; 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 checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five 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` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, `-office/` directory, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, lightweight download metadata and Office skills, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/runtime-bootstrap.mjs` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, its Office directory, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, lightweight resource metadata and skills, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. @@ -74,7 +74,7 @@ The verification surface has three tiers. Mechanism tier: the measured conclusio Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. -The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (the declared native target, or WASM when no native target is declared), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive. +The installed-wheel `sdk-office` scenario relocates the lightweight wheel payload and explicitly downloads the complete npm Office sidecar with its independent Node. It requires exactly one target backend, verifies that the SDK loads the kit from the downloaded sidecar, and converts DOCX through the skill CLI with an empty PATH. Python Office operations use the same CLI. Explicit downloads reuse the existing build preparation implementation; SDK startup performs no downloads. The [runtime reference](../../../../python/sdk-runtime/README.md) owns cache and activation behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 4cc6e20459..272c0e80c9 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -44,19 +44,19 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server。可选资源服务提供 MCP Resources;MCP Prompts 仍不受支持。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 -[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于 `-office/node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。必需包缺失或已安装包缺少清单会使复制失败;未安装的可选包保留 kit 自身的引擎选择规则。 +[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于显式下载的 sidecar 的 `node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。构建将所选引擎和依赖位置锁定到确切的 npm 归档;下载会在发布缓存前校验完整性。 ### 构建流水线与产物 -[`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.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与对应目标的 Office 目录一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。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` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 +[`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.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。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` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`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 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、`-office/` 目录和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、轻量下载元数据与 Office skills 和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/runtime-bootstrap.mjs`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、其 Office 目录及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、轻量资源元数据与 skills 及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 @@ -74,7 +74,7 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 -安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(使用已声明的原生目标,未声明原生目标时使用 WASM),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。 +安装后 wheel 的 `sdk-office` 场景迁移轻量 wheel 载荷,并显式下载完整 npm Office sidecar 及其独立 Node。它要求仅有一个目标后端,验证 SDK 从下载的 sidecar 加载 kit,并通过 skill CLI 在空 PATH 下转换 DOCX。Python Office 操作使用同一 CLI。显式下载复用现有构建准备实现;SDK 启动不执行下载。[运行时参考](../../../../python/sdk-runtime/README.zh.md)拥有缓存和启用行为的说明。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index ca2693afef..d3c4c85807 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml @@ -15,8 +15,8 @@ en: 4921a060c657b790 zh: 2f9632469c6f0b60 /agent-note-provider-routed-llm-adapters-and-a-generic-pi-ai-backend/decision/explicit-pi-ai-provider-profiles: - en: e24610c474a94bc4 - zh: 96a89035cb8eabf9 + en: 1346315312eb35e2 + zh: 3555f91c3afb49f8 /agent-note-provider-routed-llm-adapters-and-a-generic-pi-ai-backend/decision/recorded-assistant-route-and-replay-state: en: ed60d078eacc67df zh: a22631faf93deee2 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index b205104489..d06af266ec 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -36,6 +36,8 @@ The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its reg pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` rejects a defined Harness `stop` option with `UNSUPPORTED_OPTION` rather than silently ignoring it or growing a second provider-specific payload implementation. `dsh-llm-deepseek` continues to support `stop` through its native request serializer. +pi-ai owns protocol-specific system-message placement and the concatenation fallback for models without in-history system support. Harness preserves later system messages in transcript order and reports catalog capabilities to prompt admission and shared tool-history projection. Duplicating pi-ai's fallback or applying Anthropic's placement rules to every protocol would create a second serialization policy that can drift when pi-ai changes. Capability reporting remains a Harness responsibility because the loop needs it before serialization. + ### Recorded assistant route and replay state Assistant messages carry the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records those fields and `deriveMessages()` returns them with the assistant message. User, system, context, and tool-result messages carry no assistant route fields. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index b838d7255a..d07312ce99 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -36,6 +36,8 @@ Status: implemented pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定义,`dsh-llm-pi-ai` 会以 `UNSUPPORTED_OPTION` 拒绝请求,不会静默忽略,也不会增加第二套提供方专用 payload 实现。`dsh-llm-deepseek` 继续通过原生请求序列化器支持 `stop`。 +pi-ai 负责各协议的系统消息位置,以及不支持历史内系统消息的模型所需的拼接回退。Harness 按对话记录顺序保留后续系统消息,并向提示词准入和共享工具历史投影报告目录能力。重复实现 pi-ai 的回退,或将 Anthropic 的位置规则应用于所有协议,会形成第二套序列化策略,并可能随 pi-ai 的变化而偏离。能力报告仍由 Harness 负责,因为循环在序列化之前就需要这些信息。 + ### 已记录的助手路由与回放状态 助手消息携带请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index 7354602835..4573f0a33f 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -27,8 +27,8 @@ en: fbd101d4576afa92 zh: 35f5d3c2f6cad6a8 /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/alternatives-considered: - en: 1f205f1a78d4fd5b - zh: 6c17e35da2909302 + en: c4d86feb135d2ac4 + zh: 67c56c0bc2213949 /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/consequences: en: 24a9ad2c4c7c86b5 zh: debdd29d272e07ea diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md index 5ca2b44b24..967c7285df 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -62,7 +62,7 @@ No registry test can prove that arbitrary third-party same-process code observes **Forbid around wrappers from replacing the signal.** Rejected because deadlines and nested operational scopes need lexical derivation. Capturing and fusing the caller signal preserves composition without allowing detachment. -**Race the tool promise against cancellation.** Rejected because it reports completion while side effects may remain live, violating the [quiescent-disposal rule](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it). +**Race the tool promise against cancellation.** Rejected because it reports completion while side effects may remain live, violating the [quiescent-disposal rule](../../../../docs/defensive-patterns/general.md#dispose-must-reach-quiescence-not-just-request-it). ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index d6d49fb44c..87318c3316 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -62,7 +62,7 @@ Status: implemented **禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套操作作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。 -**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.zh.md#dispose-must-reach-quiescence-not-just-request-it)。 +**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns/general.zh.md#dispose-must-reach-quiescence-not-just-request-it)。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index a102c752b0..fefceef44e 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml @@ -9,11 +9,11 @@ en: c25db2ef111b2276 zh: 557891ca9e030ae5 /agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/decision: - en: 1a1af1e8125ba4b0 - zh: dd6c282651a06af5 + en: ca8044c9e0013be6 + zh: d60af7af291299c3 /agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/alternatives-considered: en: 1a4b0ae8072e8e50 zh: d469553ad3b5d112 /agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/consequences: - en: 8acf5e6f55a747c2 - zh: bbd0b10927302965 + en: e31df28a2e82a64d + zh: 4ca7097ebbc24cd2 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 397c93e084..9b0c573210 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -13,9 +13,11 @@ The web GUI host serves `/api` over plain loopback HTTP (default `127.0.0.1:3080 Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves: - **Media-type fence (dsh-client-connection)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. -- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence. +- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, a declared authority, or the listener's own bind literal that [Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md) admits. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence. -Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need. +Reachability is the webserver binding's policy (`host`; [Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md) owns which literals it accepts), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need. + +`HostConnectionHandle.allowsRemoteAuthorities` projects whether any validated `trustedHosts` entry names a non-loopback hostname: a loopback listener behind a proxy can still serve remote browsers, so the directory picker requires a policy without remote authorities before it picks the native chooser. ## Alternatives considered @@ -26,6 +28,6 @@ Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), an ## Consequences - Any future `/api` method is covered by construction; there is no per-route trust decision left to forget. -- A custom non-loopback composition must trust its serving authorities or requests are refused, then satisfy browser authentication like every loopback request. The shipped CLI rejects `--host 0.0.0.0`; `--trusted-host` only extends the Host/Origin fence and grants no identity. +- A non-loopback listener accepts its own bind IP directly ([Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md)); other remote authorities require configured trust. Every accepted authority still requires browser authentication. `--trusted-host` extends only the Host/Origin fence and grants no identity. - Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header). - Host and Origin remain request-routing evidence only. The process token and signed cookie establish the browser identity used by every Host method. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 6d3896fd2c..0721f0bf6b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -13,9 +13,11 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI 在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——分为两部分: - **媒体类型栅栏(dsh-client-connection)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 -- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、已声明的权威,或[绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md)所接纳的监听器自身绑定字面量通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 -可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。 +可达性由 webserver 的绑定配置(`host`;[绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md)拥有它接受哪些字面量)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。 + +`HostConnectionHandle.allowsRemoteAuthorities` 表示是否有通过校验的 `trustedHosts` 条目命名非回环主机名:代理后的回环监听器也能服务远程浏览器,因此目录选择器要求策略不接纳远程 authority,才会选择原生交互。 ## 曾考虑的替代方案 @@ -26,6 +28,6 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI ## 后果 - 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。 -- 自定义非 loopback 组合必须信任其服务 authority,否则请求会被拒绝;随后仍像每个 loopback 请求一样满足浏览器认证。随附 CLI 拒绝 `--host 0.0.0.0`;`--trusted-host` 只扩展 Host/Origin 栅栏,绝不授予身份。 +- 非回环监听器直接接受自己的绑定 IP([绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md));其他远程 authority 需要配置的信任。每个被接受的 authority 仍须满足浏览器认证。`--trusted-host` 只扩展 Host/Origin 栅栏,绝不授予身份。 - 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。 - Host 与 Origin 仍只是请求路由证据。进程令牌与签名 cookie 建立每个 Host 方法使用的浏览器身份。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 7afdf2c22c..7c9a3757f4 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -33,8 +33,8 @@ en: a244f6a88fdabf3d zh: e4feedb7297f4865 /agent-note-typert-gateway-targeted-method-calls/three-artifact-kinds-and-two-typescript-programs: - en: 6ea4bddcf35a834d - zh: 4af474c6818b1cf0 + en: c5bcd6eb76bf3d42 + zh: 9df2fbb364643186 /agent-note-typert-gateway-targeted-method-calls/the-remote-package-entry: en: 75e977fe0fb71d59 zh: 00760c79da173cb7 @@ -72,5 +72,5 @@ en: 7433bdb522bb2083 zh: 545c17613b636f00 /agent-note-typert-gateway-targeted-method-calls/consequences: - en: c3459afdb606cfbe - zh: ec69f2bb39170a7c + en: abab48251550f584 + zh: 2568c46d78dbefdf diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 8e91598f7d..f8eb1a8f42 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -215,7 +215,7 @@ Host lib build → Vite 构建 Web ``` -The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. +The top-level `build` runs the step graph in `scripts/build.ts`, which completes the Host bundle and the Remote artifacts before starting Client TypeScript compilation. `build:lib` remains the complete serial lib build for `build:bench`, and its Host half `build:lib:host` serves the vendor release workflows. `typecheck` writes the Remote artifacts through `scripts/generate-typert.ts`, beside Host compilation, before Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. Compiler-backed repository gates that resolve the consumer surface have the same prerequisite even when their primary inputs are source files. The public `typecheck`, `lint`, and `doc-typecheck` commands run the Host contract pass first. The gate scheduler may use their `*:contracts-ready` variants only after an explicit Typert-contract or complete-build dependency, so parallel lanes neither read missing declarations nor run concurrent generators against the same outputs. @@ -520,7 +520,7 @@ A consumer may import a Remote contract that is not currently mounted on the Hos Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the Client Remote or Gateway exposes `fetch`, an HTTP request, or a route handle, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. -Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint. +Remote endpoints use Connection's `trusted-host` authority. Loopback and the listener's own bind address are accepted by default; any other LAN authority requires an explicit trusted-host configuration. This layer adds no per-method caller authorization; every accepted authority can invoke a mounted Remote endpoint. `hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a Typert registry restart forgets the historical strict definition. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index fd6cd2bcfc..53edd345a7 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -215,7 +215,7 @@ Host lib build → Vite 构建 Web ``` -现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 +顶层 `build` 运行 `scripts/build.ts` 中的步骤图,先完成 Host bundle 与 Remote artifact,再启动 Client TypeScript 编译。`build:lib` 仍是 `build:bench` 使用的完整串行 lib 构建,其 Host 部分 `build:lib:host` 供 vendor 发布 workflow 使用。`typecheck` 与 Host 编译并行,经 `scripts/generate-typert.ts` 写出 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 即使主要输入是源文件,需要通过编译器解析消费方 surface 的仓库门禁也有相同的前置条件。公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先执行 Host 约定 pass。门禁调度器仅可在显式的 Typert 约定依赖或完整构建依赖完成后使用对应的 `*:contracts-ready` 变体,使并行 lane 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。 @@ -520,7 +520,7 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化 Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若 Client Remote 或 Gateway 暴露 `fetch`、HTTP request 或 route handle,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 -Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。 +Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback 与监听器自身的绑定地址;其他 LAN authority 必须通过显式 trusted-host 配置接入。本层不增加逐方法调用方授权,因此每个被接受的 authority 都能调用已挂载的 Remote endpoint。 `hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 Typert 注册表才会忘记历史 strict definition。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index e33e7f556d..ac13880ddd 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -9,8 +9,8 @@ en: c11f54e24be6cc3b zh: c1c274fbb161a57b /agent-note-session-log-versioning-one-integer-an-upgrade-chain-and-a-per-event-ignorable-marker/decision: - en: 084bcb4de484ef32 - zh: 1edf1445913ac7b1 + en: f2c17cd6b83acdba + zh: 148162c0a8043947 /agent-note-session-log-versioning-one-integer-an-upgrade-chain-and-a-per-event-ignorable-marker/decision/writer-and-publication-authority: en: 461eaa03c6a4f441 zh: 7e5b131326dc702c diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 9cb0940f76..c2407b2afd 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -10,15 +10,17 @@ Session logs must be upgradable after release, and the runtime that ships first ## Decision +The [reader-compatibility review decision](../process/2026-10-08-session-reader-compatibility-review.md) supersedes the structural bump criterion. This note owns the integer, unknown-event marker, direction-aware refusal, and generation-preservation decisions. + **One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance; design time rarely reveals whether the next change will turn out "major". -**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers. +**Bump when existing discriminators cannot prevent unsafe interpretation.** Successful parsing alone does not establish compatibility: silently skipping content required for reconstruction is a wrong read. Same-version changes require safe old-reader handling and preservation of historical meaning by new readers. The linked reader-compatibility decision defines the review obligations. **Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: every event-body operation first runs the complete adjacent chain in memory and leaves the source path, bytes, and inode unchanged. Read handles may consume that current logical result directly; a write open exclusively publishes the final current generation under its canonical versioned filename before append. Header-only listing remains non-mutating and reports the numerically highest canonical generation. Catalog generation and module initialization reject a missing adjacent step, so a published first-party build never exposes a partial historical chain. Retained lower generations are not automatic fallback or a downgrade compatibility promise. **A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the four `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example). -The [accepted compatibility checkpoint](../../../../docs/session-format-status.md#finalization-record) prevents an accepted transition from reusing its header increase for later breaking changes. Backward-compatible evolution uses new acknowledgement records at the same Session version; breaking changes require a higher writer. The checkpoint does not change the unknown-event protocol. +**Accepted machine records remain immutable.** The [accepted compatibility checkpoint](../../../../docs/session-format-status.md#finalization-record) preserves accepted declarations and schemas. Later changes receive new acknowledgements, and a version-bump decision needs its own increasing header transition. The checkpoint does not change the unknown-event protocol. ### Writer and publication authority diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index e1424f24e5..9a9b4365d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -10,15 +10,17 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 ## 决定 +[读取器兼容性评审决策](../process/2026-10-08-session-reader-compatibility-review.zh.md)取代结构性升版本标准。本说明负责整数版本、未知事件标记、区分方向的拒绝与代际保留决策。 + **一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺;设计时很少能预知下一个变更算不算"大"。 -**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。 +**既有判别信息无法阻止不安全解释时升版本。** 仅解析成功不能证明兼容:静默跳过重建所需内容属于错误读取。同版本变更要求旧读取器安全处理,并由新读取器保留历史含义。所链接的读取器兼容性决策定义评审义务。 **读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:每个事件正文操作先在内存中运行完整相邻链,并保持源路径、字节与 inode 不变。读句柄可以直接使用该 current 逻辑结果;写 open 则在 append 前把最终 current generation 排他发布到其规范版本文件名。仅 header 的列表保持不变更,并报告数值最高的规范 generation。catalog 生成与模块初始化会拒绝缺失的相邻步骤,因此已发布第一方 build 绝不会暴露不完整历史链。保留的低 generation 不是自动 fallback,也不构成 downgrade compatibility 承诺。 **逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经四种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。 -[已接受兼容性检查点](../../../../docs/session-format-status.zh.md#finalization-record)阻止已接受转换复用原来的头版本递增来授权后续破坏性变更。向后兼容的演进在相同 Session 版本上使用新的确认记录;破坏性变更要求更高的写入器版本。检查点不改变未知事件协议。 +**已接受的机器记录保持不可变。** [已接受兼容性检查点](../../../../docs/session-format-status.zh.md#finalization-record)保留已接受的声明与 schema。后续变更获得新确认,升版本决策需要自身的递增头版本转换。检查点不改变未知事件协议。 ### 写入器与发布真源 diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml index 3afca5571b..a5863d41c7 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml @@ -15,8 +15,8 @@ en: a1750de2d48551af zh: ffea0b75c292f186 /agent-note-client-shell-layering-and-dynamic-package-boundaries/decision/shared-module-requests: - en: cedddcca8a7bae6d - zh: a568a1130229f48c + en: 96661da17622132a + zh: dab3424093130c2f /agent-note-client-shell-layering-and-dynamic-package-boundaries/decision/parser-preloading-and-react-handoff: en: 4b503618b271fc28 zh: e2148f44efa769d2 diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md index ad2bb26b84..68c9ef1235 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md @@ -32,7 +32,7 @@ The `staticLinked` preset leaves every bare specifier as an external import in ` ### Shared module requests -Dynamic browser bundles implicitly externalize the common baseline: `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI identities, while `PRELOADED_CLIENT_EXTERNALS` is reserved for a dynamic identity that must arrive before shell boot and is currently empty. A package uses `dsh.client.external` only for an exact non-baseline value request. Type-only imports are erased and create no request; permitted third-party implementation libraries remain private bundle contents. +Dynamic browser bundles implicitly externalize the common baseline: `PLATFORM_MODULES` names shell-seeded React, Cordis, selected third-party libraries, and static UI identities, while `PRELOADED_CLIENT_EXTERNALS` is reserved for a dynamic identity that must arrive before shell boot and is currently empty. A package uses `dsh.client.external` only for an exact non-baseline value request. Type-only imports are erased and create no request; non-baseline third-party implementation libraries remain private bundle contents. Shared third-party libraries use the shell's declared versions without per-plugin version negotiation; promotion into the baseline requires a measured reduction across the shell and shipped plugin bundles. A request has exactly two suppliers: diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md index 70563962b3..79017823a7 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md @@ -32,7 +32,7 @@ Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundl ### 共享模块请求 -动态浏览器 bundle 会隐式 external 统一基座:`PLATFORM_MODULES` 命名由外壳播种的 React、Cordis 和静态 UI 身份,`PRELOADED_CLIENT_EXTERNALS` 则为必须先于 shell 启动到达的动态身份预留,当前为空。包只在精确请求基座外实体时使用 `dsh.client.external`。纯类型 import 会被擦除,不产生请求;允许的第三方实现库保留为 bundle 私有内容。 +动态浏览器 bundle 会隐式 external 统一基座:`PLATFORM_MODULES` 命名由外壳播种的 React、Cordis、选定的第三方库和静态 UI 身份,`PRELOADED_CLIENT_EXTERNALS` 则为必须先于 shell 启动到达的动态身份预留,当前为空。包只在精确请求基座外实体时使用 `dsh.client.external`。纯类型 import 会被擦除,不产生请求;基座外的第三方实现库保留为 bundle 私有内容。共享第三方库使用外壳声明的版本,不为单个插件协商版本;只有测量确认外壳与随包插件 bundle 的总体积减少,才将库纳入基座。 请求只有两种提供方: diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml index 8cc0c37e4d..ea55baad51 100644 --- a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml @@ -9,8 +9,8 @@ en: 4945ce54f66df55c zh: 9fbd833c1d8122a7 /agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/decision: - en: 2388519a1afaac92 - zh: 6bf758f0560e29a5 + en: d2452e4a39db0b6b + zh: 87f7e0be1f613285 /agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/incremental-session-log-field: en: 4e3138fbdd94259b zh: 8fda801522045b88 @@ -21,7 +21,7 @@ en: eb69576e756f4aa1 zh: 5c1923159da3f7a2 /agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/verification: - en: 9935338a0970d09d + en: ed730f117365f007 zh: a9a36702bc72a9f7 /agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/alternatives-considered: en: 5faf7699272f99b4 @@ -33,5 +33,5 @@ en: b6eff63f49220dcc zh: 8e4a6c785ed2256a /agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/consequences: - en: 853cd7b21db6208b - zh: bfaf459f006fe2db + en: fed545acefb91f2a + zh: c474a94bb956bbf6 diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md index c2ad167f14..7a6aa226b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md +++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md @@ -14,7 +14,7 @@ Both values belong only on the official DeepSeek adapter path. Adding them to `G ## Decision -`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. A merged body that fails to serialize is sent without extension fields or acceptance under the [bounded-upload decision](2026-09-24-bounded-session-log-upload.md). The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. Shipped compositions mount the registry and both contributors: both package metadata and Session-log upload are enabled by default; `session-log-deepseek.enabled: false` disables log upload under the [default-upload decision](2026-09-14-session-log-upload-default.md). Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes. +`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. A merged body that fails to serialize is sent without extension fields or acceptance under the [bounded-upload decision](2026-09-24-bounded-session-log-upload.md). The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Extension fields are auxiliary and best effort: the registry logs and omits a contributor whose preparation throws, and the adapter logs an acceptance failure without failing the model request; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. Shipped compositions mount the registry and both contributors: both package metadata and Session-log upload are enabled by default; `session-log-deepseek.enabled: false` disables log upload under the [default-upload decision](2026-09-14-session-log-upload-default.md). Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes. The provider-neutral `llm` package and `llm-pi-ai` contain no extension type, service lookup, field merge, or acceptance call. @@ -50,7 +50,7 @@ The process-lifetime manifest-identity cache remains separate because in-process ## Verification -Registry tests pin duplicate ownership, effect-scoped disposal, detached field values, concurrent and abortable preparation, receiver-preserving acceptance, one acceptance settlement, and failure aggregation. Session tests pin default-on and explicit-off policies, full-first/suffix-later delivery, direct complete event envelopes independent of base-body messages, incremental watermark folding, persisted restart recovery, fork identity fencing, and out-of-order acceptance. Package-inventory tests pin default-on and explicit-off policies, host and standing-preset discovery, conflicting Loader resolution bases, manifest resolution, lifecycle filtering, and exact name/version ordering. The direct adapter mock proves pre-HTTP preparation failure, cancellation, non-2xx non-acceptance, 2xx acceptance before a later stream failure, and field collision. Keyless replay pins post-2xx extension acceptance, and the TypeScript JSON-RPC plus Python packaged-runtime snapshots project the acceptance event through both SDKs. Real Loader composition pins default package metadata and Session upload plus explicit upload disablement, one real-API request mounts both shipped extensions and proves the official endpoint accepts them, and pi-ai tests retain their unchanged wire requests. +Registry tests pin duplicate ownership, effect-scoped disposal, detached field values, concurrent and abortable preparation, receiver-preserving acceptance, one acceptance settlement, and failure aggregation. Session tests pin default-on and explicit-off policies, full-first/suffix-later delivery, direct complete event envelopes independent of base-body messages, incremental watermark folding, persisted restart recovery, fork identity fencing, and out-of-order acceptance. Package-inventory tests pin default-on and explicit-off policies, host and standing-preset discovery, conflicting Loader resolution bases, manifest resolution, lifecycle filtering, and exact name/version ordering. The direct adapter mock proves pre-HTTP preparation rejection, cancellation, non-2xx non-acceptance, 2xx acceptance before a later stream failure, and field collision. Keyless replay pins post-2xx extension acceptance, and the TypeScript JSON-RPC plus Python packaged-runtime snapshots project the acceptance event through both SDKs. Real Loader composition pins default package metadata and Session upload plus explicit upload disablement, one real-API request mounts both shipped extensions and proves the official endpoint accepts them, and pi-ai tests retain their unchanged wire requests. ## Alternatives considered @@ -85,7 +85,7 @@ About 98% of the measured v1 real-session events were `assistant/chunk`. Omittin ## Consequences -Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. Unless Session-log upload is disabled, each eligible request also carries newly unaccepted Session events, up to `maxBytes` per request. The fields are model-hidden and add no prompt tokens or KV-cache changes, but can substantially increase HTTP body size. Manifest resolution, field collision, acceptance logging, or provider schema rejection fails the model request rather than silently dropping metadata. A merged body that fails to serialize is the exception: the request proceeds without extension fields and logs a warning. +Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. Unless Session-log upload is disabled, each eligible request also carries newly unaccepted Session events, up to `maxBytes` per request. The fields are model-hidden and add no prompt tokens or KV-cache changes, but can substantially increase HTTP body size. Field collision or provider schema rejection fails the model request. A contributor preparation failure, an acceptance failure, or a merged body that fails to serialize logs a warning and lets the request proceed; a package without a usable version reports only its name. The `delivery-accepted` event becomes part of the canonical log and is itself delivered on a later request. Crash recovery can duplicate a suffix but does not infer acceptance from assistant output or create a second local cursor store. Direct calls without a live Session omit the session field; host package inventory remains available. diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md index 91d44f59e0..608661bd5e 100644 --- a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。合并后的正文无法序列化时,按[有上限上传决策](2026-09-24-bounded-session-log-upload.zh.md)不带扩展字段发送,也不执行接受。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据和会话日志上传均默认开启;按[默认上传决策](2026-09-14-session-log-upload-default.zh.md),设置 `session-log-deepseek.enabled: false` 可关闭日志上传。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。 +`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。合并后的正文无法序列化时,按[有上限上传决策](2026-09-24-bounded-session-log-upload.zh.md)不带扩展字段发送,也不执行接受。即使贡献方忽略信号,注册表也会在取消后停止等待准备。扩展字段是辅助信息,按尽力而为处理:贡献方准备时抛错,注册表会记录告警并省略该字段;接受失败时,适配器记录告警而不使模型请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据和会话日志上传均默认开启;按[默认上传决策](2026-09-14-session-log-upload-default.zh.md),设置 `session-log-deepseek.enabled: false` 可关闭日志上传。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。 提供方无关的 `llm` 包与 `llm-pi-ai` 不包含任何扩展类型、服务查找、字段合并或接受调用。 @@ -85,7 +85,7 @@ Status: implemented ## 后果 -DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。除非关闭会话日志上传,否则符合条件的请求还会携带新的未接受会话事件,每次最多 `maxBytes`。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。Manifest 解析、字段冲突、接受记录或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。合并后的正文无法序列化是例外:请求不带扩展字段继续发送,并记录一条告警。 +DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。除非关闭会话日志上传,否则符合条件的请求还会携带新的未接受会话事件,每次最多 `maxBytes`。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。字段冲突或提供方 schema 拒绝会使模型请求失败。贡献方准备失败、接受失败或合并后的正文无法序列化时,系统记录告警并让请求继续;没有可用版本的包只报告名称。 `delivery-accepted` 事件会成为权威日志的一部分,并在后续请求中自行交付。崩溃恢复可能重复后缀,但不会根据 assistant 输出推断接受,也不会创建第二份本地游标存储。缺少存活会话的直接调用会省略会话字段;宿主包清单仍然可用。 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index 5e193c9063..ba50248d0c 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml @@ -21,8 +21,8 @@ en: 8ee88035cf64eeb3 zh: 6184e50fc4d47348 /agent-note-one-dsh-launcher-for-application-profiles/decision/python-runtime: - en: 5962abadfdece709 - zh: 5db20e96deed49d1 + en: 4b4cf5071b740c85 + zh: 595e5fe8127f9067 /agent-note-one-dsh-launcher-for-application-profiles/decision/enforcement: en: 9ce8d86b65064f38 zh: 27b8ad426cb319a2 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 7d4bb1a9c2..27a8315fe6 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -46,7 +46,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher ### Python runtime -The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar, including the `web` profile. +The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. A private `DSH_RUNTIME_DOWNLOAD` selector prepares optional resources before application launch and is removed before spawning preparation subprocesses; ordinary CLI arguments do not select downloads. The [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar, including the `web` profile. The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. [docs/architecture.md](../../../../docs/architecture.md) owns this launch, and the [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows carrier. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index eb7d5ff8eb..f9a50ebf5a 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -46,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 ### Python 运行时 -Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同的 profile 语法,包括 `web` profile。 +Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。私有 `DSH_RUNTIME_DOWNLOAD` 选择器在应用启动前准备可选资源,并在启动资源准备子进程前移除;普通 CLI 参数不选择下载。[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同的 profile 语法,包括 `web` profile。 可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[docs/architecture.md](../../../../docs/architecture.zh.md)负责该启动方式,[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 载体。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index bf89beeba0..e70e858963 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml @@ -9,11 +9,11 @@ en: 9f34c53e647c69ca zh: 551776c7cc9618b5 /agent-note-client-derived-presentation-from-raw-session-tool-events/decision: - en: bdbb99c01b4c4db0 - zh: 570040471ad1f7f4 + en: 5fcc5a3de9f757cd + zh: a693cefdb82784ee /agent-note-client-derived-presentation-from-raw-session-tool-events/decision/goals-and-non-goals: - en: 7e7a937dc4fe1b18 - zh: 530f508b7e17b607 + en: 40aceb58d71303b5 + zh: 6954e41ae2fd1efd /agent-note-client-derived-presentation-from-raw-session-tool-events/terminology: en: 021106dd41176715 zh: 131caff7bd37251b @@ -72,11 +72,11 @@ en: f40f0a8ee8c055c0 zh: 6957b7372956113c /agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/generic-tool-row: - en: 20ff82b092325c5c - zh: 1afd5441a9816117 + en: d336c77d1b09f349 + zh: 6e613f9c9108058f /agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/terminal-card: - en: eab7f366df331b6e - zh: d82499e2a982206f + en: 492669c2134169b2 + zh: 0247a38df2dd8a83 /agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/diff-card: en: ea384415c94f82da zh: 5f146c6cb534e1db @@ -99,8 +99,8 @@ en: e5b42aa311079bd7 zh: ec4cf97037ece7e0 /agent-note-client-derived-presentation-from-raw-session-tool-events/presentation-equivalence-matrix: - en: 30915c26728eeafe - zh: eea04dea7b4604a5 + en: 7974f20a6ac687ac + zh: cb4c350325a6da67 /agent-note-client-derived-presentation-from-raw-session-tool-events/client-extension-contract: en: 96619dfd5ef299cf zh: 253df98eb42ad75e diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index ee1b54f6ee..8a0c4ea6fc 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner ## Decision -The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../../../../packages/client/ui-tool/README.md) and [recorded tool details](../../../../packages/client/ui-tool/README.md); all other presentation and ownership constraints remain. +The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../../../../packages/client/ui-tool/README.md), [recorded tool details](../../../../packages/client/ui-tool/README.md), and [background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md); all other presentation and ownership constraints remain. The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view. @@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall | Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology | | Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure | | Forbidden | a new Client presenter service, parallel registry, or wire renderer id | -| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../../../../packages/client/ui-tool/README.md) and [recorded tool details](../../../../packages/client/ui-tool/README.md) | +| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../../../../packages/client/ui-tool/README.md), [recorded tool details](../../../../packages/client/ui-tool/README.md), and [background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md) | | Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field | ## Terminology @@ -294,15 +294,16 @@ Unknown tools continue to use `GenericToolCard` with the name, raw arguments, re The title, kind, rawInput, content, and locations from Generic Host `presentCall` do not currently drive an ordinary Web row. Generic `presentResult.content` also does not drive Web output, so the Client need not copy these unconsumed values. + ### Terminal card -The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, and Session cwd, independently of `parentCallId`. +The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, and Session cwd, independently of `parentCallId`. [Background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md) governs explicit background `bash`/`pwsh` launches; the remaining ownership and fallback rules below stay in force. | Input | Preserved result | |---|---| | running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state | | successful standard foreground call | terminal output, exit code/signal, and settled state presentation | -| `run_in_background:true` | Generic row and raw result | +| explicit background standard `bash`/`pwsh` | original `background` flag with call `running` at Start; acknowledgement clears `running` and retains a static command without output or job status | | tool execution error | Generic IN/OUT and error summary | | running persistent `bash`/`pwsh` | terminal prompt | | settled persistent `bash`/`pwsh` | Generic flattened result, with no new exit card | @@ -310,7 +311,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n | background/error `terminal_send` | Generic result | | PTC dispatch child | same terminal eligibility and fallback rules as a root call | -Standard shell results parse trailing `[exit code: N]` and `[killed by signal: X]` markers. A final recognized spill-policy notice selects Generic output instead: expandable in shell rows and raw in Details, because the exit marker may be displaced or omitted. A parsed marker is removed from the terminal body; timeout, sandbox denial, and markers without a pill remain in the body. +Foreground standard shell results parse trailing `[exit code: N]` and `[killed by signal: X]` markers; explicit background launch acknowledgements do not enter output or exit-status parsing. A final recognized spill-policy notice selects Generic output instead: expandable in shell rows and raw in Details, because the exit marker may be displaced or omitted. A parsed marker is removed from the terminal body; timeout, sandbox denial, and markers without a pill remain in the body. Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots. @@ -318,7 +319,7 @@ For `terminal_send`, non-empty input and the session id remain verbatim tool dat Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation. -`TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged. +Outside the explicit background command-only presentation, `TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged. ### Diff card @@ -412,7 +413,7 @@ The scenario does not import Host tool packages to compute page presentation and | orphan result | callId title and Generic output | | interrupted call | warning/stopped state | | foreground bash/pwsh | current terminal prompt, body, cwd, and state | -| background/error bash/pwsh | current Generic IN/OUT | +| explicit background bash/pwsh | terminal Start and static command-only acknowledgement; launch errors retain Generic IN/OUT | | persistent shell | current running terminal and settled Generic form | | terminal_send | current foreground terminal and background/error Generic form | | write/edit | current intended/applied diff and error fallback | diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 332c30df9b..2dd4ad8bc9 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的 ## Decision -下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../../../../packages/client/ui-tool/README.zh.md)和[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md);其他展示与所有权约束全部保留。 +下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../../../../packages/client/ui-tool/README.zh.md)、[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)和[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md);其他展示与所有权约束全部保留。 Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。 @@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa | 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology | | 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 | | 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id | -| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../../../../packages/client/ui-tool/README.zh.md)和[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)除外 | +| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../../../../packages/client/ui-tool/README.zh.md)、[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)和[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md)除外 | | 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 | ## 术语 @@ -294,15 +294,16 @@ Chat 与 Details 通过纯 helper 复用同一 block 的解析。未来缓存必 Generic Host `presentCall` 的 title、kind、rawInput、content 与 locations 当前并不驱动普通 Web 行;Generic `presentResult.content` 也不驱动 Web 输出,因此无需把这些未消费值复制到 Client。 + ### Terminal 卡片 -Client terminal model 从工具名称、调用参数、结果 content、error 与 Session cwd 派生现有 `TerminalBlock` props,不依赖 `parentCallId`。 +Client terminal model 从工具名称、调用参数、结果 content、error 与 Session cwd 派生现有 `TerminalBlock` props,不依赖 `parentCallId`。[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md)拥有主动后台 `bash`/`pwsh` 启动的展示决定;下述其余所有权和 fallback 规则继续有效。 | 输入 | 保持的结果 | |---|---| | 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 | | 标准前台 success | terminal output、exit code/signal 与结算状态呈现 | -| `run_in_background:true` | Generic 行与原始结果 | +| 标准 `bash`/`pwsh` 主动后台调用 | Start 使用原 `background` 标志和调用 `running`;回执清除 `running` 后保留静态命令,不显示输出或任务状态 | | 工具执行 error | Generic IN/OUT 与错误摘要 | | persistent `bash`/`pwsh` running | terminal prompt | | persistent `bash`/`pwsh` settled | Generic flattened result,不新增 exit card | @@ -310,7 +311,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error | `terminal_send` background/error | Generic 结果 | | PTC dispatch child | 与根调用相同的 terminal 适用规则与 fallback 规则 | -标准 shell 结果解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。末尾已识别的 spill 策略提示会改用 Generic 输出:在 shell 行中可展开,在 Details 中显示原文,因为退出标记可能被移位或省略。已解析的 marker 从 terminal 正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。 +标准前台 shell 结果解析末尾 `[exit code: N]` 与 `[killed by signal: X]`;主动后台启动回执不进入输出或退出状态解析。末尾已识别的 spill 策略提示会改用 Generic 输出:在 shell 行中可展开,在 Details 中显示原文,因为退出标记可能被移位或省略。已解析的 marker 从 terminal 正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。 调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。 @@ -318,7 +319,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error 同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。 -TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。 +除主动后台的仅命令展示外,TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本保持不变。 ### Diff 卡片 @@ -412,7 +413,7 @@ Deliverables Definition 按 callId 观察 root `tool/call` 与成功 `tool/resul | orphan result | callId 标题与 Generic output | | interrupted call | warning/stopped 状态 | | bash/pwsh 前台 | 当前 terminal prompt、正文、cwd 与状态 | -| bash/pwsh background/error | 当前 Generic IN/OUT | +| bash/pwsh 主动后台调用 | terminal Start 与静态仅命令回执展示;启动错误保留 Generic IN/OUT | | persistent shell | 当前 running terminal、settled Generic | | terminal_send | 当前前台 terminal、后台/error Generic | | write/edit | 当前 intended/applied diff 与 error fallback | diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index a67b37efe1..a48e1e3ffb 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml @@ -9,8 +9,8 @@ en: 01293500639c9d79 zh: 6d19e4843955c811 /agent-note-browser-launch-token-authentication/decision: - en: 063f9698b8a94287 - zh: 848b2c1134b7a100 + en: 54efa59339f75256 + zh: 0f53ead3844151c6 /agent-note-browser-launch-token-authentication/verification: en: 189b36d0fc5f3a17 zh: 08993cb585204fc0 @@ -18,5 +18,5 @@ en: 5275c87f19fe6eee zh: ccb922367c4bb3e2 /agent-note-browser-launch-token-authentication/consequences: - en: 0714a8a7cc74918a - zh: 0f3878b09463ab2e + en: af55274a2f345cdd + zh: 4d1810141ef03bae diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index 47d6afe1bf..0038ca7b9b 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -14,13 +14,13 @@ The Web Host runs tool-capable Sessions with the current operating-system user's Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public. -The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation. +The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. The HTTP listener omits `Secure`. There is no logout operation. The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority. The in-page Web Worker preview exposes no network socket. Its page-owned `postMessage` tunnel enters the real route first, then retries a 401 or 403 through the worker-local fetch handler. This keeps Connection interceptors while limiting the authentication bypass to the page that created the Host worker. -The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not imply supported network deployment, TLS, forwarding-header interpretation, or proxy configuration. +The shipped CLI accepts concrete local bind addresses but rejects wildcard addresses such as `--host 0.0.0.0`. Authentication does not configure TLS, forwarding-header interpretation, or proxies. ## Verification @@ -42,6 +42,8 @@ Unit coverage pins process-token retention across Connection reloads, one secret Possession of the browser cookie authorizes the complete tool-capable Host API, matching the authority the Web application exposes after Session creation. `Host` does not grant a higher method tier, and a method migration between API Proxy and Typert Remote cannot change its caller set. -The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime. Deleting the record and restarting the process is the global revocation mechanism; the active Connection intentionally avoids credential-provider work on each request. Omitting `Secure` preserves loopback HTTP and permits plaintext transmission if an operator makes the same cookie authority reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it. +The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime. Deleting the record and restarting the process is the global revocation mechanism; the active Connection intentionally avoids credential-provider work on each request. An HTTP listener permits plaintext cookie transmission if its authority is reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it. The decision partially supersedes the authentication deferral and unauthenticated non-loopback consequences in the [browser trust note](2026-07-28-api-browser-trust-boundary.md). That note remains active authority for media-type, Host, Origin, Fetch-Metadata, and configured-authority validation. No active Agent Note is archived: the overlap is partial and both security rules retain future decision value. + +The [native HTTPS listener decision](2026-09-23-native-https-web-listener.md) supersedes the HTTP-only transport and omitted-`Secure` assumptions here. Listener protocol now determines `Secure`; the token exchange, signed-cookie authority, and credential lifetime remain owned by this note. diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index 13ecf55e41..a08323b629 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -14,13 +14,13 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session 每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。 -cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作。 +cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。HTTP 监听器不设置 `Secure`。这里没有 logout 操作。 HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。 页内 Web Worker preview 不暴露网络 socket。其由页面持有的 `postMessage` tunnel 先进入真实 route,收到 401 或 403 后再经 worker 本地 fetch handler 重试。这样既保留 Connection interceptor,又把认证绕过限制在创建 Host worker 的页面内。 -随附 CLI 继续拒绝 `--host 0.0.0.0`。认证不代表支持网络部署、TLS、转发 header 解释或代理配置。 +随附 CLI 接受本机具体绑定地址,但拒绝 `--host 0.0.0.0` 等通配地址。认证不会配置 TLS、转发 header 解释或代理。 ## 验证 @@ -42,6 +42,8 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 持有浏览器 cookie 就能调用完整的工具型 Host API,这与 Web 应用在创建 Session 后暴露的 authority 一致。`Host` 不授予更高的方法层级,方法在 API Proxy 与 Typert Remote 之间迁移也不会改变调用者集合。 -持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期。删除记录并重启进程是全局撤销机制;当前 Connection 刻意避免在每个请求上访问凭据提供方。不设置 `Secure` 保留 loopback HTTP,但如果操作者让同一 cookie authority 经未加密网络可达,cookie 会以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。 +持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期。删除记录并重启进程是全局撤销机制;当前 Connection 刻意避免在每个请求上访问凭据提供方。如果 HTTP listener 的 authority 经未加密网络可达,cookie 就可能以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。 本决策部分取代[浏览器信任说明](2026-07-28-api-browser-trust-boundary.zh.md)中的认证延期与未认证非 loopback 后果。该说明仍是媒体类型、Host、Origin、Fetch-Metadata 和配置 authority 校验的有效权威。没有 active Agent Note 被归档:重叠只发生在局部,两条安全规则都保有未来决策价值。 + +[原生 HTTPS 监听器决策](2026-09-23-native-https-web-listener.zh.md)取代本说明仅支持 HTTP 传输及不设置 `Secure` 的前提。`Secure` 由监听器协议决定;令牌交换、签名 cookie 的 authority 与凭据有效期仍由本说明定义。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 981cb258f7..ad94eb0bd5 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -21,11 +21,11 @@ en: 29def175ab33b7d6 zh: 87e55ec28c20c7c1 /agent-note-package-and-update-the-electron-desktop-application/updates-and-recovery: - en: e9f140cd22670dfa - zh: bf85cfe9c12a0345 + en: 4cb7014acfe71762 + zh: 2178e0337cba0324 /agent-note-package-and-update-the-electron-desktop-application/security-and-release-policy: - en: 97ee608e96fc3602 - zh: 932b98e7d344cd87 + en: 5faaa53b58121311 + zh: e1c3fc8d21a27d1a /agent-note-package-and-update-the-electron-desktop-application/implementation: en: 041a6d35ed61b149 zh: 617c6b90a6f4fa4d diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index 26a6b9ba99..0241afef65 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -75,13 +75,13 @@ The [immediate-window reference](../../../../apps/desktop/README.md) owns the lo `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations. -Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk//`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/README.md#upload-updates) defines generation and reuse. +Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk//`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/publishing/README.md#upload-updates) defines generation and reuse. ## Security and release policy Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles. -Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. +Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. Windows packaging can instead select a machine-owned public `presets/.env`, plus `secrets/windows-signing.env` for signed builds; unsigned and preparation-only modes omit the signing file. The selected files exclusively own release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. @@ -91,7 +91,7 @@ Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled Local Windows and macOS installation testing uses an explicit `--unsigned` package invocation with the same build and runtime preparation. It strips certificate inputs, isolates artifacts in `unsigned-artifacts`, and omits updater configuration and the release completion record. macOS local builds ignore dotenv files and inherited release settings, use the standard application ID, and omit mandatory-update policy so local testing needs no deployment configuration. Their App receives a certificate-free ad-hoc signature; their DMG is unsigned and unnotarized. Windows local builds retain their platform configuration requirements. The regular package command explicitly selects signed mode even when its parent environment requests unsigned mode. This separation permits installation diagnosis without release credentials while preventing local test output from qualifying for release upload. -Windows application replacement follows the [directory-installation reference](../../../../apps/desktop/README.md): extract beside the destination with a command-line tool that returns failure status, then rename complete directories on the same volume. The installer retains the old directory through staging and restores it if promotion fails. Registration, shortcuts, and the signed uninstaller remain owned by electron-builder. +Windows application replacement follows the [directory-installation reference](../../../../apps/desktop/packaging/README.md#runtime-file-selection): extract beside the destination with a command-line tool that returns failure status, then rename complete directories on the same volume. The installer retains the old directory through staging and restores it if promotion fails. Registration, shortcuts, and the signed uninstaller remain owned by electron-builder. Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the pnpm entry, dsh resources, or active project. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 21ef91d0e0..f3075a175d 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -75,13 +75,13 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。 -测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk//`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/README.zh.md#upload-updates)定义生成与复用方式。 +测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk//`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/publishing/README.zh.md#upload-updates)定义生成与复用方式。 ## 安全与发布策略 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。 -Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。 +Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。Windows 打包也可选择机器管理的公开 `presets/.env`,签名构建另读 `secrets/windows-signing.env`;未签名和仅准备模式不读取签名文件。所选文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 @@ -91,7 +91,7 @@ Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7- 本地 Windows 和 macOS 安装测试使用显式的 `--unsigned` 打包调用,并执行相同的构建和运行时准备。它清除证书输入,将产物隔离到 `unsigned-artifacts`,并省略更新器配置和发布完成记录。macOS 本地构建忽略 dotenv 文件和继承的发布配置,使用标准应用 ID,并省略强制更新策略,使本地测试无需部署配置。其 App 使用无需证书的 ad-hoc 签名,DMG 不签名也不公证。Windows 本地构建保留平台配置要求。即使父进程环境请求未签名模式,常规打包命令也会显式选择签名模式。这样既能在没有发布凭据时诊断安装问题,也能防止本地测试产物通过发布上传校验。 -Windows 应用替换遵循[目录安装参考](../../../../apps/desktop/README.zh.md):使用能返回失败状态的命令行工具解压到目标旁边,再在同卷内改名替换完整目录。安装器在暂存期间保留旧目录,正式替换失败时恢复旧目录。注册信息、快捷方式和签名卸载器仍由 electron-builder 持有。 +Windows 应用替换遵循[目录安装参考](../../../../apps/desktop/packaging/README.zh.md#runtime-file-selection):使用能返回失败状态的命令行工具解压到目标旁边,再在同卷内改名替换完整目录。安装器在暂存期间保留旧目录,正式替换失败时恢复旧目录。注册信息、快捷方式和签名卸载器仍由 electron-builder 持有。 打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 pnpm 入口、dsh 资源 或活跃项目。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml index 72e167ce59..40b064454f 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml @@ -12,14 +12,14 @@ en: e01df5ba880c2972 zh: e62074a6a9ba5685 /agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/package-ownership: - en: 37cae6b28fd359ef - zh: 63118e361fa8f84c + en: f683605bf4655db8 + zh: de09d610ee1d4193 /agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/transactions-and-upgrades: en: 53d48f71fcc1c52e zh: 7d61923484dbec1c /agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/alternatives-considered: - en: ead5bbd94044c3de - zh: 057cf069413d2a49 + en: 67b3ba2b37e92f88 + zh: 785083e5de098af8 /agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/consequences: en: 6dfa581ec6879c5b zh: f6c735ad6a00b2b3 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md index f8f6599fe6..a1aeb68f2d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -26,7 +26,7 @@ The resource descriptor records the exact release, Node version, platform, archi The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts. The Host inherits the user environment. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability. -Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the Electron [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin. +Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, licenses, and unrecognized assets; node-pty's ConPTY source distribution is retained only for Windows targets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the Electron [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin. The shared profile runner supplies missing installation and selected-bundle dependencies through the runtime resolution. pnpm-installed packages take precedence. Runtime resolution delegates to the selected package paths, so Host and plugin imports reaching the same export share its module instance. Distinct ESM and CommonJS conditional exports remain distinct entry points; runtime resolution does not merge a package’s dual implementations. @@ -48,7 +48,7 @@ The [immediate-window reference](../../../../apps/desktop/README.md) owns direct ## Alternatives considered -Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation reference](../../../../apps/desktop/README.md) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory. +Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation reference](../../../../apps/desktop/packaging/README.md#desktop-runtime-preparation) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory. - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility. - **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. The runtime resolution supplies missing packages while pnpm-owned entries retain independent versions. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md index 6d14494858..73728d1feb 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -26,7 +26,7 @@ Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成 [桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物。Host 继承用户环境。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。 -包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;Electron 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。 +包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、许可证和未知资源;node-pty 的 ConPTY 源分发内容仅在 Windows 目标中保留;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;Electron 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。 共享 profile runner 通过 runtime resolution 补全安装包与选中 bundle 缺失的依赖。pnpm 安装的包优先。运行时解析委托给所选包路径,因此 Host 与插件导入同一导出时共享其模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;运行时解析不会合并包的双重实现。 @@ -48,7 +48,7 @@ profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。 ## 考虑过的替代方案 -完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证参考](../../../../apps/desktop/README.zh.md)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。 +完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证参考](../../../../apps/desktop/packaging/README.zh.md#desktop-runtime-preparation)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。 - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。 - **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。runtime resolution 提供缺失的包,pnpm 拥有的条目保留独立版本。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.i18n.yaml index 423de99127..6c236fcf0f 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.i18n.yaml @@ -9,8 +9,11 @@ en: f67f6add03a36e85 zh: 656be288f8b2e1d2 /agent-note-posix-ssh-execution-providers/decision: - en: b2d1e370d008f0f3 - zh: 3e98919538d3fb78 + en: bc22949098fc3028 + zh: 76618180bc0bac56 +/agent-note-posix-ssh-execution-providers/decision/helper-deployment: + en: c479fdb58ed617f1 + zh: 17cfefa778e3fd71 /agent-note-posix-ssh-execution-providers/alternatives-considered: en: 4efffb3edba91689 zh: 40100a70e5f55a42 @@ -18,8 +21,8 @@ en: f29359ab89feb084 zh: db7505b8d4a5351a /agent-note-posix-ssh-execution-providers/verification: - en: 822c7c029a77f3c0 - zh: 4a2eaaf3372cba8b + en: ee72e88fd40d6769 + zh: 56f0e0c8f3365973 /agent-note-posix-ssh-execution-providers/deferred-work: en: f300f112c9a09f7e zh: b4708a0b7f596420 diff --git a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md index d54600faa6..3bd1c340d1 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md @@ -22,12 +22,20 @@ Each stream receives a fresh 256-bit TLS pre-shared key through private administ Collected stdout and stderr carry bounded tail snapshots through handlers that only update output observations. Capture continues while a snapshot is waiting for transport. Final snapshots preserve raw-byte offsets, and completed spill files use the local provider’s retained-output storage after connection disposal. -Readiness verifies the installed helper digest and, when PTC is configured, the installed Node bootstrap digest. These checks pin expected deployment artifacts; they do not authenticate a malicious remote operating system. The helper disables Node debugger activation through `SIGUSR1`: file-effect confinement can still permit same-user signals, which must not expose the helper's unrestricted filesystem and process services. File-effect confinement delegates to the remote local sandbox provider and retains its full/partial disclosure and platform limitations. +Readiness verifies the installed helper script or executable digest and, when a script deployment supplies a PTC bootstrap, that bootstrap's digest. Executable deployments embed the PTC worker and require no separate bootstrap. These checks pin expected deployment artifacts; they do not authenticate a malicious remote operating system. The helper disables Node debugger activation through `SIGUSR1`: file-effect confinement can still permit same-user signals, which must not expose the helper's unrestricted filesystem and process services. File-effect confinement delegates to the remote local sandbox provider and retains its full/partial disclosure and platform limitations. -Path canonicalization belongs where the files exist. The shared policy resolver preserves absolute execution-world spelling; enforcing providers resolve symlinks and `..` on their own filesystem. Headless records and validates cwd through `ctx.fs`. Host-path projection remains unavailable for SSH, so Node execution requires an explicitly installed remote bootstrap. +Path canonicalization belongs where the files exist. The shared policy resolver preserves absolute execution-world spelling; enforcing providers resolve symlinks and `..` on their own filesystem. Headless records and validates cwd through `ctx.fs`. Host-path projection remains unavailable for SSH; PTC uses an explicitly installed remote bootstrap for script deployments or the helper executable's embedded worker. A lost connection invalidates pending operations without reconnect or replay. Helper EOF, signals and a heartbeat lease initiate remote native cleanup. The client cannot turn lease expiry into an observed successful termination: an interrupted mutation or launch can have an unknown outcome. Administrative request deadlines, consumer execution deadlines and cleanup remain separate owners. +### Helper deployment + +The explicit `launch.kind` selects remote `node-script` or `executable` invocation independently of the local Harness carrier. A local `process.pkg` flag cannot describe the remote installation. Protocol 2 includes the remote runtime kind in its readiness response and rejects protocol or launch-kind mismatches, so a client cannot apply script bootstrap rules to an embedded worker. Client and helper protocol upgrades must be deployed together. + +The executable embeds Node, the helper, managed subprocess runners and the PTC worker. It reuses the [SDK executable packaging decision](2026-07-10-single-file-executable-sdk-runtime-distribution.md): `pkg --sea` preserves a package tree and dynamic module resolution instead of maintaining a separate static plugin registry. That record owns the comparison with bare Node SEA. The embedded runtime serves these private entry points; project commands still depend on the remote machine's development tools. + +The four release targets cover Linux and macOS on x64 and arm64, matching the POSIX providers and their supported native resources. Packaging does not add Windows or musl support. Landlock and macOS PTY launchers remain real files beside the executable because the operating system cannot execute paths inside pkg's virtual filesystem. The installation unit is therefore the complete archive directory, with native resources and integrity metadata, rather than the executable alone. The [helper runtime reference](../../../../packages/ssh/ssh-helper-runtime/README.md) owns the build matrix, deployment baselines and archive verification commands. + ## Alternatives considered **Frame every stream over SSH exec stdout/stdin.** This avoids Unix-socket forwarding, but requires per-stream credits, queue bounds and independent EOF semantics inside the application protocol. Independent SSH channels use the transport’s existing flow control and keep program output away from administrative framing. @@ -48,7 +56,7 @@ The portable-consumer decision remains active; this note supplies its SSH realiz ## Verification -Required protocol and lifecycle evidence covers malformed frames, bounds, reservation cancellation, authenticated stream publication and disconnect errors. Native SSH acceptance must exercise remote file guards and symlink identity, Bash confinement, fd 7 binary traffic, control progress under paused output, terminal operations, LSP and Node execution. Live checks require an explicitly configured disposable remote workspace; keyless tests do not provision one. +Required protocol and lifecycle evidence covers malformed frames, bounds, reservation cancellation, authenticated stream publication and disconnect errors. Native SSH acceptance must exercise remote file guards and symlink identity, Bash confinement, fd 7 binary traffic, control progress under paused output, terminal operations, LSP and Node execution. External-host checks require an explicitly configured disposable remote workspace. Packaged Linux acceptance provisions a disposable OpenSSH container without Node; native-host checks own confinement evidence. Security evidence requires both a same-user connector and a replaced socket listener that cannot claim a reserved stream, learn its key, alter another run or receive its plaintext output. Process-lifecycle evidence distinguishes direct exit from managed-range quiescence and tests cancellation both before launch publication and after the payload starts. Source and built profile checks verify the installed helper/bootstrap arrangement without transferring host credentials to program environments. diff --git a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md index a6b3a84c37..5ad2d3c471 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md @@ -22,12 +22,20 @@ SSH 提供经过认证的字节通道及逐通道流量控制,但普通 exec 收集的 stdout 与 stderr 通过仅更新输出观测的处理器传递有界尾部快照。快照等待传输时,输出捕获继续进行。最终快照保留原始字节偏移,已完成的 spill 文件在连接释放后使用本地提供方的保留输出存储。 -就绪流程验证已安装辅助程序的摘要,并在配置 PTC 时验证已安装 Node 引导程序的摘要。这些检查固定预期部署产物,不用于认证恶意远端操作系统。辅助程序禁用通过 `SIGUSR1` 启动 Node 调试器:文件效果限制仍可能允许同用户信号,这些信号不得暴露辅助程序不受限的文件系统和进程服务。文件效果限制委托给远端本地沙箱提供方,保留其完整/部分执行披露及平台限制。 +就绪流程验证已安装辅助脚本或可执行文件的摘要;脚本部署提供 PTC 引导程序时,还验证该引导程序的摘要。可执行文件部署内嵌 PTC 工作进程,无需单独的引导程序。这些检查固定预期部署产物,不用于认证恶意远端操作系统。辅助程序禁用通过 `SIGUSR1` 启动 Node 调试器:文件效果限制仍可能允许同用户信号,这些信号不得暴露辅助程序不受限的文件系统和进程服务。文件效果限制委托给远端本地沙箱提供方,保留其完整/部分执行披露及平台限制。 -路径规范化属于文件实际存在的位置。共享策略解析器保留执行环境中的绝对路径写法;执行限制的提供方在自己的文件系统上解析符号链接与 `..`。headless 通过 `ctx.fs` 记录和验证 cwd。SSH 不提供主机路径投影,因此 Node 执行需要显式安装的远端引导程序。 +路径规范化属于文件实际存在的位置。共享策略解析器保留执行环境中的绝对路径写法;执行限制的提供方在自己的文件系统上解析符号链接与 `..`。headless 通过 `ctx.fs` 记录和验证 cwd。SSH 不提供主机路径投影;PTC 在脚本部署中使用显式安装的远端引导程序,在可执行文件部署中使用辅助程序内嵌的工作进程。 连接丢失会使待处理操作失效,不进行重连或重放。辅助进程 EOF、信号及心跳租期会启动远端原生清理。客户端不能把租期到期当作观察到的成功终止:被中断的修改或启动可能结果未知。管理请求截止时限、消费方执行截止时限及清理分别由各自归属方负责。 +### 辅助程序部署 + +显式的 `launch.kind` 选择远端的 `node-script` 或 `executable` 调用方式,与本地 Harness 的运行载体相互独立。本地 `process.pkg` 标志无法描述远端安装。协议 2 在就绪响应中包含远端运行时类型,并拒绝协议版本或启动类型不匹配,避免客户端把脚本引导规则用于内嵌工作进程。客户端与辅助程序的协议升级必须一起部署。 + +可执行文件内嵌 Node、辅助程序、托管子进程运行器与 PTC 工作进程。它复用 [SDK 可执行文件打包决策](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md):`pkg --sea` 保留包目录树与动态模块解析,无需维护独立的静态插件注册表。该记录负责与裸 Node SEA 的方案比较。内嵌运行时服务于这些私有入口;项目命令仍依赖远端机器的开发工具。 + +四个发布目标覆盖 Linux 和 macOS 的 x64 与 arm64,对应 POSIX 提供方及其支持的原生资源。打包不会增加 Windows 或 musl 支持。Landlock 与 macOS PTY 启动器保留为可执行文件旁的真实文件,因为操作系统无法执行 pkg 虚拟文件系统中的路径。因此,安装单元是包含原生资源与完整性元数据的完整归档目录,而非单个可执行文件。[辅助程序运行时参考](../../../../packages/ssh/ssh-helper-runtime/README.zh.md)负责构建矩阵、部署基线及归档验证命令。 + ## 考虑过的替代方案 **在 SSH exec stdout/stdin 上为所有流分帧。** 这避免了 Unix 套接字转发,但要求应用协议自行实现逐流额度、队列上限及独立 EOF 语义。独立 SSH 通道使用传输层既有流控,并将程序输出与管理帧分开。 @@ -48,7 +56,7 @@ SSH 提供经过认证的字节通道及逐通道流量控制,但普通 exec ## 验证 -必要的协议及生命周期证据覆盖畸形消息、上限、预留取消、经过认证的流发布及断连错误。原生 SSH 验收必须覆盖远端文件保护与符号链接身份、Bash 限制、fd 7 二进制流、输出暂停时的控制进展、终端操作、LSP 及 Node 执行。实时检查要求显式配置的可丢弃远端工作区;无密钥测试不创建远端环境。 +必要的协议及生命周期证据覆盖畸形消息、上限、预留取消、经过认证的流发布及断连错误。原生 SSH 验收必须覆盖远端文件保护与符号链接身份、Bash 限制、fd 7 二进制流、输出暂停时的控制进展、终端操作、LSP 及 Node 执行。外部主机检查要求显式配置的可丢弃远端工作区。打包产物的 Linux 验收会创建未安装 Node 的可丢弃 OpenSSH 容器;原生主机检查负责限制执行的证据。 安全证据要求同时尝试同用户连接与替换套接字监听端点,并确认其无法占用预留流、获知流密钥、修改另一执行或收到其明文输出。进程生命周期证据区分直接退出与托管进程范围静止,并测试启动发布前及程序启动后的取消。源码与构建后配置组合检查验证已安装辅助程序/引导安排,不向程序环境传递主机凭据。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml index d83f853c54..d7f60ead90 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml @@ -9,8 +9,8 @@ en: 66ac3d7ce7eb8e15 zh: abb9d41b71900bbf /agent-note-sandboxed-node-execution-for-ptc/decision: - en: cf072f53de09faca - zh: 9edd5c4a2bfaae5a + en: d6b9c22603a4f933 + zh: f324b91830078d9a /agent-note-sandboxed-node-execution-for-ptc/decision/resolved-inputs-and-policy: en: 3aef37bb4f8a8e53 zh: a7b477324d486ab3 @@ -18,8 +18,8 @@ en: 2c7a694b04951772 zh: 133b95b0477b127f /agent-note-sandboxed-node-execution-for-ptc/decision/resource-limits: - en: 84c73cf3ff33bc03 - zh: a228080683d6252c + en: 3f1d8a30f6d48243 + zh: faa7c01d7c959893 /agent-note-sandboxed-node-execution-for-ptc/alternatives-considered: en: 8ffcc2b32a2243d7 zh: 81d48d38947abc76 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md index 2104e58e8e..1259a617ad 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md @@ -12,7 +12,7 @@ The [PTC foundation](../feature/2026-06-15-ptc.md) remains responsible for regis ## Decision -`dsh-ptc-runtime-node` runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains inside this provider. +`dsh-ptc-runtime-node` in its default `per-call` mode runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains after a per-call execution. The host preserves `ELECTRON_RUN_AS_NODE` for child startup; the bootstrap removes it from the native environment before evaluation, and model-visible `process.env` stays empty. Nested Electron launches require their own explicit Node-mode selection. Desktop uses Electron as its Node executable; removing this selector launches Electron's application path instead of the PTC bootstrap. Sandbox permission changes cannot repair that launch mismatch. The macOS Desktop regression uses real Electron to verify binding writes, direct workspace writes, and rejection of writes outside the workspace under restricted policy. It requires an installed Electron binary; ordinary runtime tests cover environment filtering without that dependency. @@ -34,6 +34,8 @@ Program completion, timeout, cancellation and protocol failure all close executi The default elapsed deadline is 120 seconds, capped at 600 seconds by default. Trusted service consumers may request `timeoutMs: null` to disable this timer; [workflow sandbox reuse](2026-09-13-workflow-ptc-sandbox-reuse.md) owns that caller-controlled lifetime. Omitted and numeric requests, including model-facing `run_code`, retain the numeric defaults and caps. An enabled deadline includes runtime setup and nested tool or approval waits. V8 old-generation memory, serialized outer output and control traffic have separate configured bounds. The heap limit excludes native allocations and descendant memory, and elapsed time is not a process-tree CPU budget. +The opt-in [in-memory execution mode](2026-09-23-best-effort-persistent-ptc.md) retains disposable memory under the same file policy; [retained-process permissions](2026-10-08-persistent-ptc-sandbox.md) govern policy changes and temporary escalation. + ## Alternatives considered **Keep the worker and add tool checks.** Tool checks cannot intercept direct Node imports or establish OS confinement. Keeping a worker inside a confined supervisor process preserves worker metering but adds another execution lifetime without supplying a process-tree CPU limit. diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md index 429351cca2..0074fdf578 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md @@ -12,7 +12,7 @@ Node worker 隔离 JavaScript 状态,但不应用调用 Session 的 OS 沙箱 ## 决策 -`dsh-ptc-runtime-node` 在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。本提供方不保留 worker 或持久内核。 +`dsh-ptc-runtime-node` 的默认 `per-call` 模式在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。逐次执行结束后不保留 worker 或持久内核。 Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前将其从原生环境中删除,模型可见的 `process.env` 仍为空。嵌套启动 Electron 需要自行显式选择 Node 模式。桌面端使用 Electron 作为 Node 可执行文件;删除此选择变量会启动 Electron 应用路径,而不是 PTC bootstrap。更改沙箱权限无法修复这一启动模式不匹配。macOS 桌面端回归测试使用真实 Electron 验证绑定写入、直接工作区写入以及受限策略对工作区外写入的拒绝。该测试需要已安装的 Electron 二进制文件;普通运行时测试无需此依赖即可覆盖环境过滤。 @@ -34,6 +34,8 @@ Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前 默认经过时间截止为 120 秒,默认上限为 600 秒。可信服务消费方可以请求 `timeoutMs: null` 来禁用该定时器;[工作流沙箱复用](2026-09-13-workflow-ptc-sandbox-reuse.zh.md)负责这种由调用方控制的生命周期。省略 timeout 或使用数值的请求(包括面向模型的 `run_code`)保留数值默认值与上限。启用的截止包括运行时准备以及嵌套工具或审批等待。V8 老生代内存、序列化外层输出与控制通信具有独立配置的上限。堆限制不包含原生分配和后代内存,经过时间也不是进程树 CPU 预算。 +显式启用的[内存保留执行模式](2026-09-23-best-effort-persistent-ptc.zh.md)在相同文件策略下保留可丢弃内存;[常驻进程权限](2026-10-08-persistent-ptc-sandbox.zh.md)负责策略切换和临时提权。 + ## 考虑过的替代方案 **保留 worker 并增加工具检查。** 工具检查不能拦截直接 Node 导入,也不能建立 OS 约束。在受限监督进程中保留 worker 可以保留 worker 计量,但会增加一层执行生命周期,仍不能提供进程树 CPU 限制。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml index ae4cd23658..5441a65985 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml @@ -9,14 +9,14 @@ en: 837ebb0071a5611b zh: 23d0f0713889ead6 /agent-note-writer-acquisition-for-blank-session-reuse/decision: - en: 7f2676feb224e7ea - zh: babfda5b31dfd0fe + en: a54db0d4a97aa9b8 + zh: a52d257b657fe99b /agent-note-writer-acquisition-for-blank-session-reuse/alternatives-considered: en: 2a67a53f7f396ba8 zh: 6df6ccc071fb0de2 /agent-note-writer-acquisition-for-blank-session-reuse/consequences: - en: 2e1f1894337689a2 - zh: d0cab07cc48083ee + en: 8307612b7476e8f3 + zh: 87fa39e58d33d4f1 /agent-note-writer-acquisition-for-blank-session-reuse/verification: - en: 4680e845e2ab294e - zh: ea5890c5ffb09831 + en: 4367d55ae9dce5c1 + zh: c610626f4e15eb77 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md index c832604dfb..d6be46b3ed 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md @@ -10,7 +10,7 @@ Two Hosts sharing Session storage can select the same blank Session when opening ## Decision -The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` resumes that Session and acquires its writer before opening history. Only `session/writer-held` triggers creation of a new Session. Other errors propagate. Concurrent Workspace connects in one Client share the acquisition and fallback. Later navigation cancels a pending startup selection. +The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` names that Session; the Host resumes it and acquires its writer before opening history, and creates a new Session when another owner holds the writer ([reuse and preset rules](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Other errors propagate. A Workspace connect in one Client joins the latest in-flight connect to that Workspace only when both request the same preset. Later navigation cancels a pending startup selection. The Agent retains the acquired handle throughout its lifetime. Checking ownership and releasing a probe lock would leave a race before resume. No PID or process identity enters persisted data or the Remote schema. @@ -28,8 +28,8 @@ This decision extends the blank-reuse policy in the [Client Session scope refere ## Consequences -A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts New Session and is currently reported only to the console; hidden blanks have no recovery action in this flow. +A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts the navigation; a hero Workspace pick or explicit draft preparation reports it as a notice and startup restoration only to the console, and a hidden blank that fails this way has no recovery action in this flow. ## Verification -Navigation tests cover saved selection, single-candidate contention fallback, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt. +Navigation tests cover saved selection, opening the Session the Host returns for a held candidate, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt. diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md index f5d5d58bc6..d477e9770c 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 会恢复该 Session,并在打开历史前取得写锁。只有 `session/writer-held` 会触发新建 Session。其他错误返回调用方。同一 Client 的并发 Workspace 连接共享获取与回退过程。后续导航会取消尚未完成的启动选择。 +Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 指明该 Session;Host 恢复它并在打开历史前取得写锁,写锁被其他写者持有时改为新建 Session([复用与 preset 规则](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。其他错误返回调用方。同一 Client 中的 Workspace 连接仅在与该 Workspace 最近一次进行中的连接请求相同 preset 时与之合并。后续导航会取消尚未完成的启动选择。 Agent 在整个生命周期内持有取得的句柄。检查占用后释放探测锁,会在恢复前留下竞态。持久化数据与 Remote schema 均不引入 PID 或进程身份。 @@ -28,8 +28,8 @@ Agent 在整个生命周期内持有取得的句柄。检查占用后释放探 ## Consequences -Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止新建会话,目前只报告到控制台;此流程没有针对隐藏空白会话的恢复操作。 +Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止该次导航;在 hero 中选择工作区或显式准备草稿时以提示展示该错误,启动恢复只报告到控制台,以此方式失败的隐藏空白会话在此流程中没有恢复操作。 ## Verification -导航测试覆盖已保存选择、单候选锁争用回退、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。 +导航测试覆盖已保存选择、为被占用候选打开 Host 返回的 Session、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml index 70d50d1b1a..455daa7fff 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml @@ -9,8 +9,8 @@ en: 725f773bb350218e zh: dd8fbc09ef48208d /agent-note-declarative-agent-presets-and-retained-revisions/decision: - en: 53fd72bf4bbe4eb3 - zh: 406eee6e4f818e7c + en: 88edb925b556300c + zh: c6f05b641513d768 /agent-note-declarative-agent-presets-and-retained-revisions/alternatives-considered: en: 09f40eab4659e9f6 zh: 7a70da0d0ae6845b diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md index fe7df55cde..38fa045ef1 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md @@ -16,7 +16,7 @@ The registry owns each revision's scope and Loader tree. Agents link their own s The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host. -Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md). +Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition; reusing a blank Session for a new conversation does not resolve it ([decision](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md). ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md index abe1ab27ae..4da53eb366 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md @@ -16,7 +16,7 @@ Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数 Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。 -会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。 +会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复;为新对话复用空白会话时不解析该标识([决策](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.i18n.yaml new file mode 100644 index 0000000000..f729428b68 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-19-concrete-web-bind-address.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.md +/agent-note-bind-one-concrete-web-address: + en: f70f2b6b24ef16dd + zh: 64100dbc55eee33d +/agent-note-bind-one-concrete-web-address/problem: + en: ffc265e61eadc31b + zh: fcc70f0a8817914c +/agent-note-bind-one-concrete-web-address/decision: + en: 5fe4f2ddba2ee2da + zh: bb140a16f056d4b4 +/agent-note-bind-one-concrete-web-address/alternatives-considered: + en: 0e4d475347796821 + zh: e621e606bc5b1045 +/agent-note-bind-one-concrete-web-address/consequences: + en: ba7a839bca09992d + zh: a11171005405d1e2 +/agent-note-bind-one-concrete-web-address/testing: + en: 4bb8f4bf4ad943c3 + zh: 2e24d56d01b52574 diff --git a/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.md b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.md new file mode 100644 index 0000000000..3f6be5132e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.md @@ -0,0 +1,35 @@ +# Agent Note: Bind one concrete Web address + +Status: implemented + +English | [中文](2026-09-19-concrete-web-bind-address.zh.md) + +## Problem + +The Web listener accepted `127.0.0.1` or `0.0.0.0`. A wildcard bind exposed every interface of the machine, so the bundle sampled the LAN IPv4 addresses at bind time into a `webRuntime` service and appended them to `trustedHosts`, granting authorities for interfaces the operator never named and mixing inferred reachability into configured trust. A container or workstation that should expose exactly one address had no way to say so, IPv6 addresses were not accepted at all, and a browser dialing the exposed address sent it as `Host` only to be refused unless the sampled list happened to contain it. + +## Decision + +`webserver.config.host` accepts one concrete IPv4 or IPv6 address literal, loopback by default; the unspecified address in every spelling (`0.0.0.0`, `::`, `::ffff:0.0.0.0`, and their expansions) is rejected when the config loads, and the shipped `dsh web` command refuses the same spellings for `--host` before any consumer activates. An IPv6 `%zone` is kept for `listen`; classification through the exported `isWildcardHost` and `isLoopbackHost` reads the address alone. + +The browser Host/Origin fence accepts the listener's own bind IP literal as `Host` on any port, independently of `trustedHosts` and ignoring the zone. A browser dialing that literal presents it as `Host`, the literal is no rebinding target, and the Origin and cross-site checks still apply. DNS names resolving to the address, other interfaces, and proxy authorities still require `--trusted-host`. Connection reads the invocation authorities directly from the `webStartup` provider; the `webRuntime` service and LAN sampling no longer exist. + +The advertised URL uses IPv4 for mapped loopback binds, brackets other IPv6 binds, drops a loopback zone, and requires `--public-url` for a zoned non-loopback bind because a zone cannot appear in a URL. A non-loopback bind serves plain HTTP and warns at startup even when an HTTPS `--public-url` fronts it. `web-runtime` is a required startup entry, so a bind failure fails startup instead of leaving a silent listener-less process. Desktop pins `--host 127.0.0.1`, the address Electron dials. + +## Alternatives considered + +**Keep sampling LAN addresses into `trustedHosts`.** Rejected: the listener already owns the concrete bind address. Sampling grants authorities for other interfaces and mixes inferred reachability with configured trust; direct bind-IP acceptance preserves that distinction without another runtime service. + +**Accept the bind IP only on the bound port.** Rejected: a port-less `trustedHosts` entry already matches any port, a proxy on the same address may listen elsewhere, and the port adds nothing to the rebinding defense the Host check provides. + +**Keep `0.0.0.0` with a warning.** Rejected: binding every interface exposes remote code execution to every network the machine joins, and the operator can name each address that should be exposed. + +## Consequences + +An operator exposes exactly the interfaces named by `--host` and reaches the listener through that literal without further configuration; every other remote authority is a `--trusted-host` decision. Compositions that bound `0.0.0.0` fail at load with a message naming the rejected spelling. IPv6-only hosts are supported end to end, including the printed and opened URL. + +The [native HTTPS listener decision](2026-09-23-native-https-web-listener.md) supersedes this note's HTTP-only transport assumption. The non-loopback warning applies only without TLS; bind-address validation and authority admission remain unchanged. + +## Testing + +`packages/host/webserver/tests/webserver.spec.ts` classifies wildcard and loopback spellings and rejects wildcard config; `packages/bundle/web-app/tests/startup.spec.ts` refuses wildcard `--host` before consumers activate; `packages/client/connection/tests/api-request-trust.host.spec.ts` and `node-half.host.spec.ts` admit the bind literal on any port without admitting other authorities; `packages/bundle/web-app/tests/web-app.spec.ts` covers the bracketed IPv6 URL and the plain-HTTP warning; `apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts` boots the built profile on a non-loopback and a zoned loopback address; `web-failure-matrix.expected.e2e.ts` and `packages/boot/app-boot/tests/app-boot.spec.ts` report the required `web-runtime` entry. diff --git a/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.zh.md b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.zh.md new file mode 100644 index 0000000000..4df6b8384f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 绑定一个具体的 Web 地址 + +Status: implemented + +[English](2026-09-19-concrete-web-bind-address.md) | 中文 + +## 问题 + +Web 监听器只接受 `127.0.0.1` 或 `0.0.0.0`。通配绑定会暴露机器的每个接口,因此 bundle 在绑定时把 LAN IPv4 地址采样进 `webRuntime` 服务并追加到 `trustedHosts`,为操作者从未点名的接口授予 authority,并把推断的可达性混入配置的信任。应当只暴露一个地址的容器或工作站无法表达这一点,IPv6 地址完全不被接受,浏览器拨号已暴露地址时把它作为 `Host` 发送,却只在采样列表恰好包含它时才被接受。 + +## 决策 + +`webserver.config.host` 接受一个具体的 IPv4 或 IPv6 地址字面量,默认回环;未指定地址的所有拼写(`0.0.0.0`、`::`、`::ffff:0.0.0.0` 及其展开形式)在配置加载时被拒绝,随附的 `dsh web` 命令在任何消费者激活前对 `--host` 拒绝同样的拼写。IPv6 的 `%zone` 保留给 `listen`;通过导出的 `isWildcardHost` 与 `isLoopbackHost` 分类时只读取地址本身。 + +浏览器 Host/Origin 栅栏接受监听器自身的绑定 IP 字面量作为任意端口上的 `Host`,独立于 `trustedHosts` 并忽略 zone。拨号该字面量的浏览器把它作为 `Host` 呈现,该字面量不是重绑目标,Origin 与跨站检查仍然生效。解析到该地址的 DNS 名称、其他接口与代理 authority 仍需 `--trusted-host`。Connection 直接从 `webStartup` 提供方读取调用 authority;`webRuntime` 服务与 LAN 采样不再存在。 + +公告 URL 对 mapped 回环绑定使用 IPv4,为其他 IPv6 绑定加方括号、剥离回环 zone,并因 zone 不能出现在 URL 中而要求带 zone 的非回环绑定显式提供 `--public-url`。非回环绑定提供明文 HTTP,即使前置 HTTPS 的 `--public-url` 也会在启动时警告。`web-runtime` 是必需的启动条目,因此绑定失败会使启动失败,而不是留下一个没有监听器的静默进程。Desktop 固定 `--host 127.0.0.1`,即 Electron 拨号的地址。 + +## 备选方案 + +**继续把采样的 LAN 地址加入 `trustedHosts`。** 否决:监听器已经持有具体绑定地址。采样会授权其他接口的 authority,并把推断的可达性混入配置的信任;直接接受绑定 IP 可以保留两者的区分,无需另设运行时服务。 + +**只在绑定端口上接受绑定 IP。** 否决:不带端口的 `trustedHosts` 条目本就匹配任意端口,同一地址上的代理可能监听其他端口,端口对 Host 检查提供的重绑防御没有增益。 + +**保留 `0.0.0.0` 并给出警告。** 否决:绑定所有接口会向机器加入的每个网络暴露远程代码执行,而操作者可以逐个点名应暴露的地址。 + +## 影响 + +操作者只暴露 `--host` 点名的接口,并无需额外配置即可通过该字面量访问监听器;其他每个远程 authority 都是一个 `--trusted-host` 决定。曾绑定 `0.0.0.0` 的组合在加载时失败,并给出点名被拒绝拼写的消息。纯 IPv6 主机得到端到端支持,包括打印与打开的 URL。 + +[原生 HTTPS 监听器决策](2026-09-23-native-https-web-listener.zh.md)取代本说明仅支持 HTTP 传输的前提。非回环警告只适用于未启用 TLS 的监听器;绑定地址校验与 authority 接纳保持不变。 + +## 测试 + +`packages/host/webserver/tests/webserver.spec.ts` 分类通配与回环拼写并拒绝通配配置;`packages/bundle/web-app/tests/startup.spec.ts` 在消费者激活前拒绝通配 `--host`;`packages/client/connection/tests/api-request-trust.host.spec.ts` 与 `node-half.host.spec.ts` 在任意端口上接纳绑定字面量而不接纳其他 authority;`packages/bundle/web-app/tests/web-app.spec.ts` 覆盖带方括号的 IPv6 URL 与明文 HTTP 警告;`apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts` 在非回环地址与带 zone 的回环地址上启动已构建的 profile;`web-failure-matrix.expected.e2e.ts` 与 `packages/boot/app-boot/tests/app-boot.spec.ts` 报告必需的 `web-runtime` 条目。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml index 604c1fe195..41376c7c73 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml @@ -30,8 +30,8 @@ en: 743f2dceda3072aa zh: f4d53ddc13a4127e /agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/decision/the-client-store-separates-cached-and-sequenced-rows: - en: 8a4e9e9ac2ada06f - zh: b5884535cd934f72 + en: dd1cc7ac96a5a25d + zh: bdea08d20f166e65 /agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/decision/what-stays-untouched: en: 1cefba02bbfb2647 zh: 5a96942565ab9d72 diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md index 5ef27cb076..f0235d8040 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md +++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md @@ -97,7 +97,7 @@ Writer classification: | the `projections` block of an `api-session/added` summary (`manager.handleSessionAdded`) | by the block's `kind`; the summary comes from a live session, so in practice `sequenced` | | the history first page's `projections` (`projections.seed` in `session.ts`) | sequenced | | the control baseline, live sessions only (`manager.replaceControlBaseline`) | sequenced | -| the `session.projections` result of `refreshProjections` (a body observation) | sequenced | +| the `session.projections` result of `refreshProjections` | sequenced for an exact read; cached only when [migration is required](2026-09-29-explicit-session-history-preparation.md) | | push frames (the `projection` frame handled by `manager`) | sequenced | | the `title` after a successful rename (`session.ts`) | sequenced | diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md index e69db37e7e..326e6f028b 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md @@ -97,7 +97,7 @@ seq 比较只在同一条 Host 连接内发生:`handleConnected` 先整表 `cl | `api-session/added` 摘要的 `projections` block(`manager.handleSessionAdded`) | 按 block 的 `kind`;该摘要来自活会话,实际为 `sequenced` | | history 首页 `projections`(`session.ts` 的 `projections.seed`) | sequenced | | control 基线,仅活会话(`manager.replaceControlBaseline`) | sequenced | -| `refreshProjections` 的 `session.projections` 结果(正文观察) | sequenced | +| `refreshProjections` 的 `session.projections` 结果 | 准确读取为 sequenced;仅[需要迁移](2026-09-29-explicit-session-history-preparation.zh.md)时为 cached | | 推送 frame(`manager` 处理 `projection` frame) | sequenced | | rename 成功后的 `title`(`session.ts`) | sequenced | diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml new file mode 100644 index 0000000000..615a8d43cf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-23-best-effort-persistent-ptc.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md +/agent-note-node-ptc-execution-modes-and-in-memory-state: + en: 078a89753b06cd6a + zh: 4004e5e4bb42f2cc +/agent-note-node-ptc-execution-modes-and-in-memory-state/problem: + en: e42db40b879fdbf4 + zh: 6017da9c4da17835 +/agent-note-node-ptc-execution-modes-and-in-memory-state/decision: + en: d2d08c54c6520f02 + zh: 520a9b3421ff5c65 +/agent-note-node-ptc-execution-modes-and-in-memory-state/alternatives-considered: + en: 396157a9fc293198 + zh: a061f05b4423e26f +/agent-note-node-ptc-execution-modes-and-in-memory-state/consequences: + en: 9b9172f4aafe907f + zh: 022a6bd52d7caf05 diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md new file mode 100644 index 0000000000..153a63b87e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md @@ -0,0 +1,37 @@ +# Agent Note: Node PTC execution modes and in-memory state + +Status: implemented + +English | [中文](2026-09-23-best-effort-persistent-ptc.zh.md) + +## Problem + +Interactive automation retains application objects, imported modules and helper functions between model calls. Recreating every object for every program prevents consumers from using ordinary JavaScript references across observations and actions. + +## Decision + +The existing `@deepseek-ai/dsh-ptc-runtime-node` plugin selects its execution mode through `executionMode`. The default `per-call` mode starts a fresh process under the configured file policy. Opt-in `persistent-in-memory` mode keeps a Node REPL process per explicit, object-identity state owner. Both modes provide the same `ctx.ptcRuntime` service. PTC uses the live Agent as owner and awaits release during its context disposal. Changing the configuration reloads the provider and releases its processes. The [sandboxed Node decision](2026-09-11-sandboxed-node-ptc-runtime.md) owns per-call confinement; the [PTC foundation](../feature/2026-06-15-ptc.md) owns tool dispatch and logged model requests. + +Mode names describe state retention rather than a process implementation or source syntax. `per-call` retains state for one invocation; `persistent-in-memory` retains it across invocations until the process or owner ends. `durable` names the deferred ability to save and restore execution state across restarts. It is not an accepted configuration value: a future implementation must define its recovery point, recoverable values and external-resource handling before exposing that mode. The [retained-process permission decision](2026-10-08-persistent-ptc-sandbox.md) owns confinement, policy changes and temporary escalation. + +One npm package and one plugin entry keep existing installation and loading paths stable. Communication, binding proxies, JSON encoding and output limits remain package-local modules. The Node provider delegates retained process ownership to an internal execution object, which registers no additional Cordis service. Per-call and in-memory execution keep their distinct lifecycle rules. `programFormat` describes source evaluation independently of retention: a REPL need not retain state. The runtime owns syntax, completion and retention instructions through `executionInstructions`; tools own call arguments, SDK declarations and image attachment. Workflow uses `programFormat` to wrap its program and refuses restricted file policies when the selected runtime cannot enforce them. + +The Session records submitted programs, nested tool calls and returned output. It does not serialize the REPL heap or rerun earlier programs after restart, resume or fork: replaying automation could repeat external side effects. A missing variable is a normal program error, and the model-visible instructions require recreating variables and reacquiring resources. No environment identifier or new durable Session event is required because the provider exposes no unlogged model input. + +Cancellation, deadline expiry, protocol or output-limit failure, owner disposal and process exit discard the environment. Ordinary exceptions retain earlier mutations. A retained function uses the active evaluation's host bindings; asynchronous work from an earlier evaluation cannot acquire a later evaluation's tool authority or output sink. + +## Alternatives considered + +Separate npm providers plus a shared library allow independent distribution, at the cost of another package, dependency declarations and cross-package exports for shared implementation details. The two Node modes have no current need for separate release schedules or independent dependencies. A second plugin subpath permits separate loading but asks deployments to replace plugin entries to select an execution policy. A configuration field expresses that selection while preserving the existing entry. Independent distribution or substantially different dependencies can justify revisiting this choice. + +A new Cordis service for common helpers would require another registration and injected dependency without adding an independently replaceable runtime capability. Internal functions and classes provide the required reuse. A common execution superclass would also couple fresh-process cleanup with owner-retained lifetimes; the provider shares mechanisms without merging those lifecycles. + +Serializing arbitrary JavaScript objects cannot preserve native resources and closures. Replaying prior programs can repeat writes and input actions. Both would imply recovery guarantees that in-memory execution does not offer. + +A realm inside the Harness process shares process lifetime with model code and cannot terminate a synchronous loop independently. A managed child process permits cancellation and resource cleanup while retaining imports and variables during healthy use. Process ownership alone does not provide a file sandbox or multi-tenant isolation. + +## Consequences + +Selecting `persistent-in-memory` retains execution state; the [permission decision](2026-10-08-persistent-ptc-sandbox.md) defines its file restrictions. Nested tools keep their normal authorization and logging. Memory and native handles may disappear without a Harness restart. The model must recover from reference or resource errors; the runtime never retries a program automatically. + +An idle owner retains a process with no expiration timer. Callers release owners explicitly; ownerless service calls use temporary processes. Programs use REPL statements and a final expression rather than top-level return, and calls for one retained owner cannot overlap. Tool schemas and program guidance follow the selected mode. diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md new file mode 100644 index 0000000000..a4c4a52828 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Node PTC 执行模式与内存状态 + +Status: implemented + +[English](2026-09-23-best-effort-persistent-ptc.md) | 中文 + +## 问题 + +交互式自动化需要在模型调用之间保留应用对象、导入模块和辅助函数。如果每次执行都重新创建对象,消费者就无法在观察与操作之间继续使用普通 JavaScript 引用。 + +## 决策 + +现有 `@deepseek-ai/dsh-ptc-runtime-node` 插件通过 `executionMode` 选择执行模式。默认的 `per-call` 模式按配置的文件策略启动新进程。显式启用的 `persistent-in-memory` 模式为每个以对象身份标识的状态所有者保留一个 Node REPL 进程。两种模式都提供同一个 `ctx.ptcRuntime` 服务。PTC 使用存活的 Agent 作为所有者,在其上下文销毁时等待释放。修改配置会重载提供方并释放其进程。[Node 沙箱决策](2026-09-11-sandboxed-node-ptc-runtime.zh.md)负责逐次执行的文件约束,[PTC 基础决策](../feature/2026-06-15-ptc.zh.md)负责工具分派与模型请求日志。 + +模式名称描述状态保留方式,不绑定进程实现或代码语法。`per-call` 只为一次调用保留状态;`persistent-in-memory` 跨调用保留状态,直到进程或所有者结束。`durable` 用于命名未来跨重启保存与恢复执行状态的能力。它不是可接受的配置值:实现必须先明确恢复点、可恢复的值以及外部资源的处理方式,才能公开这个模式。[常驻进程权限决策](2026-10-08-persistent-ptc-sandbox.zh.md)负责文件约束、权限切换与临时提权。 + +一个 npm 包和一个插件入口保持现有安装与加载路径稳定。通信、绑定代理、JSON 编码和输出限制继续作为包内模块复用。Node 提供方将保留进程的所有权交给内部执行对象,该对象不注册额外的 Cordis 服务。逐次执行和内存保留各自管理不同的生命周期。`programFormat` 描述代码求值形式,与状态保留独立:REPL 不必跨调用保留状态。运行时通过 `executionInstructions` 负责语法、完成值和状态保留指引;工具层负责调用参数、SDK 声明和图片附加。工作流根据 `programFormat` 包装程序;所选运行时无法执行受限文件策略时,工作流拒绝执行。 + +Session 记录提交的程序、嵌套工具调用和返回输出。它不序列化 REPL 堆,也不在重启、恢复或派生后重新执行旧程序,因为重放自动化可能重复外部操作。缺失变量产生普通程序错误,模型指引要求重新创建变量并获取资源。提供方没有引入日志之外的模型输入,因此不需要环境标识或新的持久 Session 事件。 + +取消、截止时间到期、协议失败、输出超限、所有者销毁和进程退出都会丢弃环境。普通异常保留此前发生的修改。保留的函数使用当前执行的宿主绑定;较早执行中的异步任务不能获取后续执行的工具权限或向其输出。 + +## 考虑过的替代方案 + +将提供方拆成独立 npm 包并增加共享库,可以分别分发,但需要维护额外的包、依赖声明和共享实现的跨包导出。两种 Node 模式目前没有不同发布节奏或独立依赖的需求。第二个插件子路径允许独立加载,但要求部署配置通过替换插件入口来选择执行策略。配置字段直接表达这个选择,并保留已有入口。需要独立分发或出现明显不同的依赖时,可以重新评估拆包。 + +为公共辅助代码新增 Cordis 服务,会增加注册和注入依赖,却没有提供独立替换的运行能力。内部函数和类已经能够完成复用。共同的执行基类还会把新进程清理与按所有者保留进程的生命周期耦合起来,因此提供方共用处理机制,各自管理生命周期。 + +序列化任意 JavaScript 对象无法保留原生资源和闭包。重放旧程序可能重复写入和输入操作。这两种做法都会暗示内存保留模式没有提供的恢复保证。 + +Harness 进程内的独立执行上下文与模型代码共享进程生命周期,无法单独终止同步死循环。托管子进程可以在正常使用时保留导入和变量,同时支持取消与资源清理。仅有进程所有权不能提供文件沙箱或多租户隔离。 + +## 影响 + +选择 `persistent-in-memory` 会保留执行状态;[权限决策](2026-10-08-persistent-ptc-sandbox.zh.md)定义其文件限制。嵌套工具仍遵循原有授权和日志规则。即使 Harness 没有重启,内存和原生句柄也可能丢失。模型需要从引用或资源错误中恢复,运行时不会自动重试程序。 + +空闲所有者保留一个没有过期计时器的进程。调用方显式释放所有者,没有所有者的服务调用使用临时进程。程序使用 REPL 语句和末尾表达式,不支持顶层 return;同一保留所有者的调用不能重叠。工具 schema 和程序指引跟随所选模式。 diff --git a/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.i18n.yaml new file mode 100644 index 0000000000..0aad921cce --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-23-native-https-web-listener.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.md +/agent-note-native-https-on-the-web-listener: + en: bdb6e7794af113f0 + zh: b9f325b0cbe8d34d +/agent-note-native-https-on-the-web-listener/problem: + en: 685a00e6f84f642d + zh: 404e463f56ec5a1f +/agent-note-native-https-on-the-web-listener/decision: + en: 7f9ea8a4f2577d2f + zh: e9b2a1fc6944428e +/agent-note-native-https-on-the-web-listener/alternatives-considered: + en: 44c051936dd2529e + zh: 7588669ec4c302ef +/agent-note-native-https-on-the-web-listener/consequences: + en: 6a8be07b24f957ba + zh: fbf48476e092dfc8 +/agent-note-native-https-on-the-web-listener/testing: + en: f92f7ec224dba94d + zh: 0f0e0304c834f0af diff --git a/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.md b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.md new file mode 100644 index 0000000000..123f762559 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.md @@ -0,0 +1,41 @@ +# Agent Note: Native HTTPS on the Web listener + +Status: implemented + +English | [中文](2026-09-23-native-https-web-listener.zh.md) + +## Problem + +A directly reached Web listener needs to encrypt launch tokens and session cookies without requiring a separate reverse proxy. Advertising an HTTPS URL does not encrypt the listener or establish certificate trust. + +## Decision + +WebServer owns optional TLS through `tls: { certFile, keyFile }`, exposed by `dsh web` as paired `--tls-cert` and `--tls-key` flags. Certificate and unencrypted private-key PEM files are loaded before binding; unreadable, empty, invalid, or mismatched material fails activation without an HTTP fallback. HTTP remains the default. Both transports share routing, upgrades, and disposal; disposal also owns incomplete TLS handshakes. + +`webServer.protocol` is the source for the bind-address URL, the non-loopback plain-HTTP warning, and Connection's `Secure` cookie attribute. Neither forwarded headers nor `publicUrl` control encryption or that attribute. Explicit Host-port grants normalize under the listener's scheme, while Origin comparison uses the Origin's HTTP(S) scheme so HTTPS proxies can retain HTTP upstreams. TLS grants no additional Host authority and does not replace authentication. + +The API authority check does not equate a proxy's upstream transport with the browser page's scheme. An HTTPS listener can accept a matching HTTP Origin, but `sec-fetch-site: cross-site` still rejects the request and authentication remains required. Schemeful `SameSite=Strict` cookies prevent cross-scheme browser credential delivery; this is not support for serving a public browser frontend over HTTP with a TLS upstream. + +HTTP cookie audiences retain their existing spelling so enabling TLS support does not invalidate unrelated HTTP sessions. HTTPS cookie names and signed audiences include the `https:` scheme, preventing replay of an HTTP credential on any HTTPS port even if the cookie is renamed. Default ports follow URL normalization without a separate port-443 spelling. + +Certificate issuance, trust, renewal, and replacement belong to the operator. Files are read once per activation; replacement requires a listener reload or process restart. The certificate must cover the hostname or IP the TLS client dials. The default port remains 3080; `--port` selects another port, subject to operating-system permission. + +## Alternatives considered + +**Require a TLS-terminating proxy.** This remains supported, but requires another service for a directly reached listener. + +**Derive transport or cookie security from `publicUrl` or forwarding headers.** An advertisement does not describe the receiving socket, and request headers cannot establish trusted proxy identity. Native listener state keeps this decision independent of proxy policy. + +**Issue, renew, or generate certificates.** This would add certificate lifecycle and trust-distribution responsibilities. An automatically generated certificate would not establish browser trust. + +**Fall back to HTTP on certificate errors.** This would expose credentials despite an explicit request for encryption; activation must fail instead. + +## Consequences + +Direct HTTPS needs no TLS-terminating proxy. An HTTPS proxy in front of an HTTP listener still owns external cookie rewriting. An HTTPS listener emits `Secure` cookies, so public browser-facing deployments must retain HTTPS rather than depend on local-browser exceptions for HTTP. Deployment procedures belong to the [public deployments guide](../../../../docs/user/guide/public-deployments.md). + +This partially supersedes the HTTP-only assumptions in [browser launch-token authentication](2026-08-24-browser-token-authentication.md) and [concrete bind addresses](2026-09-19-concrete-web-bind-address.md). Those notes remain active for cookie payloads and lifetimes, and bind admission respectively. + +## Testing + +The WebServer suite covers verified HTTPS, invalid material, and disposal. Connection covers listener-owned cookie attributes, cross-protocol credential replay, and exact-port grants; Web startup rejects unpaired flags. The built CLI's `public-url.expected.e2e.ts` authenticates over verified TLS with a `Secure` cookie while retaining HTTP and proxy cases; its failure matrix verifies unreadable TLS material exits without opening a listener. Desktop's startup suite covers matching WS/WSS credentials and refusal on a different transport. diff --git a/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.zh.md b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.zh.md new file mode 100644 index 0000000000..c34ad19b31 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Web 监听器原生 HTTPS + +Status: implemented + +[English](2026-09-23-native-https-web-listener.md) | 中文 + +## Problem + +浏览器直接访问的 Web 监听器需要在不依赖独立反向代理的情况下加密启动 token 与会话 cookie。公告 HTTPS URL 不会加密监听器,也不会建立证书信任。 + +## Decision + +WebServer 通过 `tls: { certFile, keyFile }` 拥有可选 TLS,由 `dsh web` 的成对 `--tls-cert` 与 `--tls-key` flag 暴露。证书与不带口令的私钥 PEM 文件在绑定前加载;材料不可读、为空、无效或不匹配时激活失败,绝不回退到 HTTP。HTTP 仍为默认值。两种传输共用路由、升级与释放;释放也负责未完成的 TLS 握手。 + +`webServer.protocol` 决定绑定地址 URL、非 loopback 明文 HTTP 警告及 Connection 的 `Secure` cookie 属性。转发 header 与 `publicUrl` 都不控制加密或该属性。显式 Host 端口授权按监听器的 scheme 归一化,Origin 比较则按 Origin 的 HTTP(S) scheme 进行,以允许 HTTPS 代理保留 HTTP 上游。TLS 不授予额外的 Host authority,也不替代认证。 + +API authority 校验不会把代理的上游传输等同于浏览器页面的 scheme。HTTPS 监听器可接受 authority 匹配的 HTTP Origin,但 `sec-fetch-site: cross-site` 仍会拒绝请求,且请求仍须认证。区分 scheme 的 `SameSite=Strict` cookie 阻止浏览器跨 scheme 携带凭据;这不表示支持以 HTTP 向公开浏览器前端提供页面、同时使用 TLS 上游。 + +HTTP cookie audience 保留既有写法,避免启用 TLS 支持时使无关的 HTTP 会话失效。HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使修改 cookie 名称,HTTP 凭据也不能在任何 HTTPS 端口上重放。默认端口遵循 URL 归一化,不为 443 端口另设写法。 + +证书签发、信任、续期与更换归操作者负责。每次激活只读取一次文件;更换后需重新加载监听器或重启进程。证书必须覆盖 TLS 客户端拨号的 hostname 或 IP。默认端口仍为 3080;`--port` 可选择其他端口,但受操作系统权限约束。 + +## Alternatives considered + +**要求终止 TLS 的代理。** 仍然支持,但会为直接访问的监听器增加一个服务。 + +**根据 `publicUrl` 或转发 header 推导传输或 cookie 安全属性。** 公告不描述接收请求的 socket,请求 header 也不能建立可信代理身份。原生监听器状态使这一决策独立于代理策略。 + +**签发、续期或生成证书。** 这会增加证书生命周期与信任分发职责。自动生成证书本身不能建立浏览器信任。 + +**证书错误时回退到 HTTP。** 这会在操作者明确要求加密时暴露凭据;激活必须失败。 + +## Consequences + +直接 HTTPS 不需要终止 TLS 的代理。HTTP 监听器前的 HTTPS 代理仍负责外部 cookie 重写。HTTPS 监听器签发 `Secure` cookie,因此公开部署的浏览器侧必须保持 HTTPS,而不应依赖本地浏览器对 HTTP 的例外。部署步骤归[公开部署指南](../../../../docs/user/guide/public-deployments.zh.md)所有。 + +本决策部分取代[浏览器启动 token 认证](2026-08-24-browser-token-authentication.zh.md)与[具体绑定地址](2026-09-19-concrete-web-bind-address.zh.md)中仅支持 HTTP 的前提。这些说明分别继续定义 cookie 载荷与有效期及绑定准入,保持活跃。 + +## Testing + +WebServer 套件覆盖校验证书的 HTTPS、无效材料与释放。Connection 覆盖由监听器决定的 cookie 属性、跨协议凭据重放和精确端口授权;Web 启动拒绝未配对的 flag。已构建 CLI 的 `public-url.expected.e2e.ts` 使用 `Secure` cookie 经校验证书的 TLS 完成认证,并保留 HTTP 与代理用例;失败矩阵验证 TLS 材料不可读时进程退出且不打开监听器。Desktop 启动套件覆盖匹配的 WS/WSS 凭据及不同传输下的拒绝。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.i18n.yaml index 64f1ca3e65..cc7f108d4c 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.i18n.yaml @@ -15,5 +15,5 @@ en: 88985b4e1f5c7a4f zh: 32617f7ff75ee825 /agent-note-bounded-deepseek-session-log-upload/consequences: - en: e19fb6136ffd2250 - zh: 01dacd7a44c3f837 + en: 288834c514df377a + zh: a33a4af2b4d9ebf4 diff --git a/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.md b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.md index b980765eed..27ae7e4117 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.md +++ b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.md @@ -25,6 +25,6 @@ This supersedes complete-suffix delivery and fail-closed serialization in the [r ## Consequences -A backlog drains at up to 8 MiB per accepted request by default. At the end-to-end rate measured for the 269 MiB upload, about 8 MiB/s, an 8 MiB field adds about 1 s to each request while a backlog drains, and a 0.86 GB backlog drains in about 100 requests. In the same 96,341-event Session, the median per-step append is 11 KiB and the largest event is 414 KiB, so ordinary steps stay far below the limit and only an unusual event or a small configured limit reaches the oversized-event path. While a backlog drains, the endpoint receives a `throughSeq` behind the Session's current tip. Preparation failures, acceptance failures, and provider rejections still fail the model request. +A backlog drains at up to 8 MiB per accepted request by default. At the end-to-end rate measured for the 269 MiB upload, about 8 MiB/s, an 8 MiB field adds about 1 s to each request while a backlog drains, and a 0.86 GB backlog drains in about 100 requests. In the same 96,341-event Session, the median per-step append is 11 KiB and the largest event is 414 KiB, so ordinary steps stay far below the limit and only an unusual event or a small configured limit reaches the oversized-event path. While a backlog drains, the endpoint receives a `throughSeq` behind the Session's current tip. Provider rejections still fail the model request; preparation and acceptance failures are logged and do not. Plugin tests cover the exact byte boundary, UTF-8 counting, backlog draining across accepted requests, the oversized-event warning with its unchanged watermark, an event past the string limit, and the 8 MiB default through plugin configuration. Adapter tests cover the base-body fallback through the provider plugin's logger and through an adapter without a reporter. diff --git a/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.zh.md b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.zh.md index 13dd90ed0a..8e2d6d4785 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.zh.md @@ -25,6 +25,6 @@ DeepSeek 适配器把合并后请求正文的序列化失败视为扩展失败 ## 后果 -默认情况下,积压按每次已接受请求最多 8 MiB 补传。按那次 269 MiB 上传实测的端到端速度(约 8 MiB/s),补传期间 8 MiB 的字段让每次请求多约 1 秒,0.86 GB 的积压约 100 次请求补完。在同一个 96,341 条事件的会话中,每步追加量的中位数是 11 KiB,最大单条事件是 414 KiB,因此普通步骤远低于上限,只有异常事件或很小的配置上限才会走到超限事件路径。积压补传期间,端点收到的 `throughSeq` 会落后于会话当前末尾。准备失败、接受失败与提供方拒绝仍会使模型请求失败。 +默认情况下,积压按每次已接受请求最多 8 MiB 补传。按那次 269 MiB 上传实测的端到端速度(约 8 MiB/s),补传期间 8 MiB 的字段让每次请求多约 1 秒,0.86 GB 的积压约 100 次请求补完。在同一个 96,341 条事件的会话中,每步追加量的中位数是 11 KiB,最大单条事件是 414 KiB,因此普通步骤远低于上限,只有异常事件或很小的配置上限才会走到超限事件路径。积压补传期间,端点收到的 `throughSeq` 会落后于会话当前末尾。提供方拒绝仍会使模型请求失败;准备失败与接受失败只记录告警,不会使请求失败。 插件测试覆盖精确字节边界、UTF-8 计数、跨已接受请求的积压补传、超限事件告警及其不变的水位、超过字符串上限的事件,以及经插件配置生效的 8 MiB 默认值。适配器测试覆盖经提供方插件 logger 报告的基础正文回退,以及没有 reporter 的适配器上的同一回退。 diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml new file mode 100644 index 0000000000..01cda717dd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-27-progressive-office-skills.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md +/agent-note-progressive-office-skills-with-ordinary-shell-execution: + en: 42e2f062c37e9ef1 + zh: 5cd3d421adec1cac +/agent-note-progressive-office-skills-with-ordinary-shell-execution/problem: + en: a9984a594cdac98d + zh: 38768e7699bc8d39 +/agent-note-progressive-office-skills-with-ordinary-shell-execution/decision: + en: d057c558d6b9803c + zh: acf3fdbc4cd7cee3 +/agent-note-progressive-office-skills-with-ordinary-shell-execution/alternatives-considered: + en: 8bca955255e6d982 + zh: bc313a4bcab3f6f9 +/agent-note-progressive-office-skills-with-ordinary-shell-execution/consequences: + en: cd4ff2423b2f5f9e + zh: 63135aeae9c46415 diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md new file mode 100644 index 0000000000..a89b896fe6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md @@ -0,0 +1,32 @@ +# Agent Note: Progressive Office skills with ordinary shell execution + +Status: implemented + +English | [中文](2026-09-27-progressive-office-skills.zh.md) + +## Problem + +Combined authoring and rendering instructions spend context on operations unrelated to ordinary editing. Rendering tool activation adds a second discovery path and durable state even though shell tools already own execution permissions and process management. A missing renderer can prevent loading otherwise usable editing guidance. + +## Decision + + +The [Office provider](../../../../packages/skill/skill-office/README.md) lists a compact router and six format-specific authoring or operation skills. The model loads full instructions by name and selects rendering for appearance-focused changes unless the user explicitly skips it. Rendering uses a private, randomly named temporary directory inside the task workspace and removes it after inspection. Skills invoke the shared Python checker and configured LibreOffice Kit CLI through ordinary Bash or PowerShell. SDK and Desktop own their Office composition separately; ordinary Web and the shared base do not mount these plugins. + +Carriers own interpreter and resource locations. Operation skills append the configured Node and CLI paths only when their bodies are loaded; authoring skills use the selected Python and shared checker resource. Loading instructions does not prepare engines or authoring payloads. Ordinary shell execution retains its existing permissions and process management. + +Office dependency ranges allow patch updates; the root lock records the resolved adapter and engine versions. + +## Alternatives considered + +**Keep a dedicated renderer tool.** A small typed schema constrains rendering arguments, but requires its own execution configuration, scoped registration, projection, and activation lifecycle. The CLI operation skills accept shell quoting and model-selected arguments in exchange for reusing the existing approval, sandbox, cancellation, and output mechanisms. + +**Hide subskills from the catalog.** This requires a generic visibility policy independent of invocation permission. Listing seven short summaries preserves the existing skill API while full instruction bodies remain demand-loaded. + +**Automatically inject guides for file extensions.** Extensions do not distinguish text edits, recalculation, conversion, and requested visual checks. Model-selected routing keeps unrelated operations out of context and permits explicit direct invocation. + +## Consequences + +Instruction loading appends ordinary logged skill results; completion does not erase them. Historical tool records remain readable and existing declaration reconciliation handles resumed sessions without a Session format change or agent-loop modification. A dedicated renderer would need a new independently justified user or security requirement before reintroduction. + +Verification covers catalog entries, denied and explicit invocation, authoring without executable paths, native/PTC requests, resume and fork, ordinary and minimal profile composition, resource packaging, context budgets, and real-model operation selection. Rendering failures preserve usable sources and stop visual checks; successful commands alone cannot establish usable images. diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md new file mode 100644 index 0000000000..0aeb95eb1d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Office 技能渐进加载与普通 shell 执行 + +Status: implemented + +[English](2026-09-27-progressive-office-skills.md) | 中文 + +## 问题 + +合并的创作和渲染说明会为普通编辑无关的操作消耗上下文。渲染工具激活增加第二条发现路径和持久状态,而 shell 工具已经负责执行权限及进程管理。缺少渲染器也可能阻止加载原本可用的编辑指南。 + +## 决策 + + +[Office 提供者](../../../../packages/skill/skill-office/README.zh.md) 列出精简路由和六个各格式创作或操作技能。模型按名称加载完整说明;以外观为目标的修改会选择渲染,除非用户明确要求跳过。渲染使用任务工作区内随机命名的私有临时目录,并在检查后删除。技能通过普通 Bash 或 PowerShell 调用共享 Python 检查器和配置的 LibreOffice Kit CLI。SDK 和 Desktop 分别拥有 Office 组合;普通 Web 和共享 base 不挂载这些插件。 + +载体负责解释器和资源位置。操作技能仅在加载正文时附加配置的 Node 与 CLI 路径;创作技能使用选定的 Python 和共享检查器资源。加载说明不准备引擎或创作 payload。普通 shell 执行保留现有权限和进程管理。 + +Office 依赖范围允许补丁版本更新;根锁文件记录实际解析的适配器与引擎版本。 + +## 考虑过的替代方案 + +**保留专属渲染工具。** 小型类型化 schema 能约束渲染参数,但需要专属执行配置、作用域注册、投影和激活生命周期。CLI 操作技能接受 shell 引用和模型选择参数的成本,以复用现有审批、沙箱、取消及输出机制。 + +**从目录隐藏子技能。** 这需要独立于调用权限的通用可见性策略。列出七条简短摘要可保留现有技能 API,完整说明仍按需加载。 + +**按文件扩展名自动注入指南。** 扩展名无法区分文本编辑、重计算、转换和用户要求的视觉检查。模型选路使无关操作不进入上下文,并允许显式直接调用。 + +## 影响 + +说明加载追加普通的已记录技能结果;操作完成不会擦除这些结果。历史工具记录仍可读取,恢复会话使用现有声明更新机制,无需修改 Session 格式或 agent loop。重新引入专属渲染器需要新的、独立成立的用户或安全要求。 + +验证覆盖目录条目、拒绝及显式调用、无可执行文件路径时的创作、native/PTC 请求、恢复和 fork、常规及 minimal 组合、资源打包、上下文预算和真实模型操作选择。渲染失败时保留可用源文件并停止视觉检查;命令成功本身不能证明图片可用。 diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml new file mode 100644 index 0000000000..ddd6d80912 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-29-explicit-session-history-preparation.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md +/agent-note-open-the-session-when-migration-is-required: + en: d409772933407317 + zh: ac258a6d1743965f +/agent-note-open-the-session-when-migration-is-required/problem: + en: ab63c8127d68d294 + zh: d3f99489c96ff1d4 +/agent-note-open-the-session-when-migration-is-required/decision: + en: 32627d744a6e7410 + zh: 2836a95ee5390111 +/agent-note-open-the-session-when-migration-is-required/alternatives-considered: + en: baea781018a49252 + zh: bcc452029bf229fd +/agent-note-open-the-session-when-migration-is-required/consequences: + en: 979eebb53a6bf711 + zh: 013743b0b7a000ce diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md new file mode 100644 index 0000000000..84b30b6cb4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md @@ -0,0 +1,43 @@ +# Agent Note: Open the Session when migration is required + +Status: implemented + +English | [中文](2026-09-29-explicit-session-history-preparation.zh.md) + +## Problem + +Expanding a subagent catalog or forking from the sidebar can trigger an old Session's format migration. + +## Decision + +The WorkspaceBrowser row-menu fork request carries `allowMigration: false`, which the Host handles by checking `stat` format information before calling the original fork flow. Only a source format requiring migration is refused, with an app-wide Toast offering to open the original Session. That action only navigates and does not retry fork. Neither an absent live Session nor unopened Client history justifies refusal. + +Other fork callers omit that option and allow automatic migration. + +`session.projections` also checks the stored format first; current-format cold history still uses the original observation flow for exact projections. Success returns `kind: sequenced` and its real `asOfSeq`; required migration returns `kind: migration-required` hints. The Client retains that read state, and cached values cannot replace sequenced values published by history or control. + +The `formatStatus` returned by `stat` carries the migration library's existing classification; `header` remains the current logical format. It describes the stored format, not migration-task progress or prepared in-memory history. + +When migration is required and `subagentCatalog` is absent, Web marks the Session's row “Migration required” and keeps its history-opening actions without descendant expansion or changing its subagent mode. Opening the Session supplies its catalog through history and control projections. Available catalogs remain usable, including empty catalogs. Session-list lineage resolves navigation addresses, not missing catalog membership. Ordinary loading, cancellation, and storage errors do not become this migration branch. + +Create/adopt, full-text search, and ordinary history opening allow automatic migration. + +This decision adds a migration exception to [catalog projection reads](../../archived/simplification/2026-09-08-web-subagent-catalog-projections.md). Exact reads and leases in [Session observations](2026-08-25-session-observations-and-projection-owned-client-state.md), and identity checks and cached/sequenced ordering in [projection-cache reads](2026-09-19-projection-cache-listing-identity-and-cached-rows.md), still apply. + +## Alternatives considered + +**Treat every cold Session as needing migration.** Residency and stored format are different; this would reject otherwise valid forks and discard catalog information available on disk. + +**Globally forbid forks that automatically migrate on the Host.** The sidebar's interaction choice does not govern other callers, so its request explicitly carries the restriction. + +**Automatically open and retry the sidebar fork.** Opening is an explicit user action, and fork does not run automatically after migration. + +**Derive catalogs from Session-list parent ids.** This adds a second membership derivation; explicit Session opening obtains the catalog. + +**Give cached hints `asOfSeq: -1`.** Hints have no event cut in current history and cannot participate in sequence comparisons. + +## Consequences + +Sessions requiring migration use the existing opening flow to prepare history; Sessions that need no migration remain cold-readable and forkable. Catalog hints may lag the log and yield to subsequent exact projections. + +A separate follow-redirection proposal discusses future single-Session migration progress; this decision does not implement that flow. diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md new file mode 100644 index 0000000000..31925f7a9f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 需要迁移时引导打开会话 + +Status: implemented + +[English](2026-09-29-explicit-session-history-preparation.md) | 中文 + +## 问题 + +展开 subagent 目录或从侧栏 fork 可能触发旧会话的格式迁移。 + +## 决定 + +WorkspaceBrowser 行菜单的 fork 请求携带 `allowMigration: false`,由 Host 在调用原 fork 流程前检查 `stat` 的格式信息。只有源格式需要迁移时才拒绝,并显示带“打开原会话”操作的应用级 Toast。该操作只导航,不自动重发 fork。没有 live Session 或 Client 尚未打开历史不构成拒绝理由。 + +其他 fork 调用者省略该选项,允许自动迁移。 + +`session.projections` 同样先检查存储格式,当前格式冷历史继续通过原观察流程读取准确投影。成功时返回 `kind: sequenced` 和真实 `asOfSeq`;需要迁移时返回 `kind: migration-required` 的缓存提示。Client 保留这一读取状态,缓存值不能覆盖 history 或 control 已发布的有序值。 + +`stat` 返回的 `formatStatus` 携带迁移库已有的分类;`header` 仍是当前逻辑格式。它描述存储格式,不描述迁移任务进度或内存准备缓存。 + +在需要迁移且缺少 `subagentCatalog` 时,Web 将该会话行标为“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode。打开会话后,历史与 control 投影提供其目录。已有目录仍可使用,包括空目录。会话列表的谱系用于解析导航地址,不用于补齐缺失的目录成员。普通加载、取消和存储错误不转换成这一迁移分支。 + +create/adopt、全文搜索和普通历史打开允许自动迁移。 + +本决策为[目录投影查询](../../archived/simplification/2026-09-08-web-subagent-catalog-projections.md)增加迁移例外。[会话观察](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)的准确读取和租约,以及[投影缓存读取](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md)的身份校验与缓存/有序分层继续适用。 + +## 考虑过的替代方案 + +**把所有冷会话都视作需要迁移。** 是否驻留与存储格式不同;这样会拒绝可以正常完成的 fork,并丢失能够从磁盘读取的目录信息。 + +**Host 全局禁止自动迁移的 fork。** 侧栏的交互选择不适用于其他调用者,因此限制由该请求显式携带。 + +**侧栏 fork 自动打开并重试。** 打开会话由用户明确选择,fork 不在迁移后自动执行。 + +**按会话列表中的父 id 派生目录。** 这需要第二套成员派生逻辑;显式打开会话即可获取目录。 + +**让缓存提示携带 `asOfSeq: -1`。** 提示没有当前历史的事件切点,不能参与序号比较。 + +## 影响 + +需要迁移的会话通过现有打开流程完成准备;无需迁移的会话仍可冷读和 fork。目录的缓存提示可能落后于日志,后续准确投影替换它们。 + +后续单会话迁移进度由独立的 follow 重定向提案讨论,本决策不实现该流程。 diff --git a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.i18n.yaml index ee7758db34..c8cf21b92d 100644 --- a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.i18n.yaml @@ -9,8 +9,8 @@ en: 5e10347fa87f98b5 zh: cf3569631af8d66f /agent-note-anonymous-translation-remains-separate-from-model-history/decision: - en: f13c134f53ed63c9 - zh: 868aacd453d26c82 + en: 3c4745cc96d89ffd + zh: 939d5cd494346311 /agent-note-anonymous-translation-remains-separate-from-model-history/alternatives-considered: en: 083d50f8564b0378 zh: 50af332e64ad06f3 diff --git a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.md b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.md index 0fa78afa4e..1e67961bf1 100644 --- a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.md +++ b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.md @@ -14,7 +14,7 @@ The experimental translator owns explicit Google or Bing routing, provider-langu Bing is the configured default. Enabling the bundle permits transmission of expanded reasoning to the chosen service, disclosed in plugin metadata and settings. Failure never selects another provider. Original remains available; translated text neither replaces persisted assistant output nor enters model input. The [optional-bundle admission policy](2026-09-21-experimental-capabilities-as-optional-bundles.md) keeps the feature disabled until selected. -The reasoning-body chain carries original text and streaming status. Native Chat owns its original Markdown fallback; consumers do not replace the assistant renderer or alter Session projection. Only expanded bodies start translation. Collapsing, changing selection, or unloading aborts outstanding work and excludes late results. +The GUI registers a translation wrapper in the standard `conversation.chat.reasoning.body` Slot and calls the official `conversation.chat.reasoning.content` Factory for original or translated text. The default body is also an ordinary registration; the host supplies no inline Markdown fallback or plugin action state. The translation wrapper owns its toolbar and passes its localized labels to the Factory, which supplies default labels when callers omit them and fixes compact typography. The [reasoning Body Slot and Content Factory design](2026-10-09-reasoning-content-slot-factory.md) supersedes this record's body composition mechanism; external requests remain separate from model history. Consumers do not replace the assistant renderer or alter Session projection. Only expanded bodies start translation. Collapsing, changing selection, or unloading aborts outstanding work and excludes late results. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.zh.md b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.zh.md index 293f74a5ee..4c2bb2854c 100644 --- a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.zh.md @@ -14,7 +14,7 @@ Status: implemented 配置的默认服务为 Bing。启用 Bundle 允许将展开的思考内容发送给所选服务,插件元数据与设置公开说明接收方。失败时不选择其他 Provider。原文始终可访问;译文不替换持久化 assistant 输出,也不进入模型输入。[可选 Bundle 准入规则](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md) 保证功能在显式选择前保持关闭。 -reasoning-body chain 传递原始文本与流式状态。原生 Chat 负责原始 Markdown fallback;消费者不替换 assistant 渲染器,也不改变 Session projection。只有展开内容才开始翻译。折叠、切换设置或卸载都会中止未完成工作并忽略迟到结果。 +GUI 向标准 `conversation.chat.reasoning.body` Slot 注册翻译包装,并调用官方 `conversation.chat.reasoning.content` Factory 展示原文或译文。默认正文同样是普通注册项;宿主不提供内联 Markdown fallback 或插件操作状态。翻译包装自行维护工具栏,并将自己的本地化 labels 传入 Factory;Factory 在调用者省略 labels 时提供默认文案,并固定紧凑排版。[思考正文的 Slot 与 Factory 设计](2026-10-09-reasoning-content-slot-factory.zh.md)取代本记录的正文组合方式,外部请求与模型历史隔离的决策保持不变。消费者不替换 assistant 渲染器,也不改变 Session projection。只有展开内容才开始翻译。折叠、切换设置或卸载都会中止未完成工作并忽略迟到结果。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml new file mode 100644 index 0000000000..262e03bb22 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-08-persistent-ptc-sandbox.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md +/agent-note-retained-ptc-processes-follow-file-policy: + en: ef07a82c85e490e1 + zh: b1662d1970d2b83b +/agent-note-retained-ptc-processes-follow-file-policy/problem: + en: b8cb574032d4e6e4 + zh: 6a1a25dec5f96086 +/agent-note-retained-ptc-processes-follow-file-policy/decision: + en: 5109a4bb5be39867 + zh: 2dcc602aba9eae61 +/agent-note-retained-ptc-processes-follow-file-policy/alternatives-considered: + en: 10bebc7f982cf7f4 + zh: 9247a2ba57921a11 +/agent-note-retained-ptc-processes-follow-file-policy/consequences: + en: b3ddf273158d3754 + zh: 916356f4074b3622 diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md new file mode 100644 index 0000000000..2c4235fcc3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md @@ -0,0 +1,31 @@ +# Agent Note: Retained PTC processes follow file policy + +Status: implemented + +English | [中文](2026-10-08-persistent-ptc-sandbox.zh.md) + +## Problem + +Retaining JavaScript variables does not require broader file access. A process that outlives one execution can also outlive a one-time approval or a Session policy change. File-policy changes must invalidate retained environments and begin stopping their file handles and background work. + +## Decision + +`persistent-in-memory` starts its Node process through the same sandbox provider as `per-call`. A retained owner uses one resolved mode, workspace root and Session identity until release. The [memory decision](2026-09-23-best-effort-persistent-ptc.md) owns retained values, failure recovery and package composition; this note owns file authority. + +The [PTC-owned cleanup decision](2026-10-09-ptc-owned-policy-cleanup.md) supersedes this note’s permission-setter completion and cleanup-ownership decision. Permission changes commit synchronously; PTC owns immediate cancellation and waits internally before subsequent execution. The tools package keeps Agent ownership and omits the owner for temporary escalations. + +An approved one-time escalation omits the state owner and receives a fresh temporary environment. Its process is released before the call returns. It cannot access standing variables, and its declarations do not survive. The standing environment keeps its original policy and variables. Nested tools continue to apply their own permissions and approvals. + +## Alternatives considered + +**Unconfined retained execution.** Omitting confinement simplifies a temporary prototype but couples variable retention to full host file access. The existing process sandbox supports long-lived processes, so this grant is unnecessary. + +**Changing the running process policy.** Existing backends apply restrictions at launch. Arbitrary retained objects, open handles and background work cannot be transferred into another policy with the existing interface. Releasing the environment prevents later evaluations from reusing those resources. + +**Coordinating through permission presets.** File-policy changes also have direct callers. PTC observes committed Session mode events from both paths, independently of user-facing preset selection. + +**Keeping an elevated environment.** A one-time approval cannot authorize future programs or idle background work. Omitting the state owner makes the approval lifetime explicit while preserving the standing environment. + +## Consequences + +Normal calls retain variables without changing file permissions. Changing sandbox mode discards variables. Escalated programs must be self-contained. Runtime consumers must release a retained owner before supplying a different policy without a Session mode change; the provider rejects inconsistent reuse. The sandbox inherits the existing backends’ file-effect coverage and platform limitations. diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md new file mode 100644 index 0000000000..d841702941 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 保留状态的 PTC 进程遵循文件策略 + +Status: implemented + +[English](2026-10-08-persistent-ptc-sandbox.md) | 中文 + +## 问题 + +保留 JavaScript 变量不需要更大的文件访问权限。跨调用存活的进程也可能在单次审批结束或 Session 策略变化后继续运行。文件策略变化必须使保留环境失效,并开始停止其文件句柄和后台工作。 + +## 决策 + +`persistent-in-memory` 与 `per-call` 通过同一个沙箱提供方启动 Node 进程。保留状态的所有者在释放前使用固定的已解析权限模式、工作区根目录和 Session 身份。[内存决策](2026-09-23-best-effort-persistent-ptc.zh.md)负责保留值、失败恢复和包组成;本文负责文件权限。 + +[PTC 自行等待清理的决策](2026-10-09-ptc-owned-policy-cleanup.zh.md)替代本文关于权限设置完成时点和清理归属的决策。权限变化同步提交;PTC 负责立即取消,并在后续执行前内部等待清理。工具包保留 Agent 所有权,在临时提权时省略所有者。 + +单次获准的提权省略状态所有者,使用全新的临时环境。该次调用返回前释放进程,无法访问原环境的变量,也不会保留新声明。原环境保持原权限和变量。嵌套工具继续应用各自的权限和审批规则。 + +## 考虑过的替代方案 + +**不限制保留状态的执行环境。** 省略沙箱能简化临时原型,却把变量保留与完整宿主文件访问权限绑定。现有进程沙箱支持长期运行的进程,因此这项授权没有必要。 + +**修改运行中进程的策略。** 现有后端在启动时应用限制。现有接口无法把任意保留对象、打开的句柄和后台工作转移到另一策略。释放环境可以防止后续执行复用这些资源。 + +**通过权限预设协调清理。** 文件策略也有直接调用方。PTC 观察两类调用方提交的 Session 模式事件,无需依赖面向用户的预设选择。 + +**保留提权环境。** 单次审批不能授权未来程序或空闲期间的后台工作。省略状态所有者明确了审批生效的范围,同时保留原环境。 + +## 影响 + +普通调用保留变量,同时维持原文件权限。切换沙箱模式会丢弃变量。提权程序必须包含执行所需的完整代码。未发生 Session 模式变化时,运行时消费者在传入不同策略前必须释放保留状态的所有者;提供方拒绝策略不一致的复用。沙箱继承现有后端的文件约束范围与平台限制。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.i18n.yaml new file mode 100644 index 0000000000..ac38c709dc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-09-message-node-factories-and-delegates.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md +/agent-note-domain-image-slots-and-a-shared-gallery-factory: + en: bce6358fb4211261 + zh: bfebd3bbdb1affb1 +/agent-note-domain-image-slots-and-a-shared-gallery-factory/problem: + en: ec6ffd7f737a1f56 + zh: 5e368f87ce52e28c +/agent-note-domain-image-slots-and-a-shared-gallery-factory/decision: + en: b807777fd48560eb + zh: e7b1625bd4448ce1 +/agent-note-domain-image-slots-and-a-shared-gallery-factory/alternatives-considered: + en: db14fd6d0e2ce78b + zh: c29a19cde692a1dd +/agent-note-domain-image-slots-and-a-shared-gallery-factory/verification: + en: d82e036edb981e7c + zh: 5a05e9a2e6489d21 +/agent-note-domain-image-slots-and-a-shared-gallery-factory/consequences: + en: ac387ffb5a316920 + zh: ab584aaafe554278 diff --git a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md new file mode 100644 index 0000000000..054dbe24ac --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md @@ -0,0 +1,54 @@ +# Agent Note: Domain image Slots and a shared gallery Factory + +Status: implemented + +English | [中文](2026-10-09-message-node-factories-and-delegates.zh.md) + +## Problem + +User messages, assistant output, Trajectory records, and tool results need independently replaceable image presentation. Their official entries can share one gallery, but sharing that implementation must not merge their replacement positions or hide the caller's image-reading authority. + +## Decision + +Each domain declares its own image Slot and supplies the framework's `renderSlot` seat to the component that renders it. The official attachment entries use one reusable Content Factory, following the [Body Slot and Content Factory division](2026-10-09-reasoning-content-slot-factory.md). + +| Entry | Declaration owner and renderer | +| --- | --- | +| `conversation.chat.user.images` Slot | The `conversation.chat.user.content` Factory declares it; `UserContent` renders it for user messages, pending steering, and local echoes. | +| `conversation.chat.message.images` Slot | The Assistant node registration declares it; `AssistantMarkdown` renders consecutive output-image groups. | +| `conversation.trajectory.images` Slot | The Trajectory view registration declares it; record attachments and tool-output blocks render it through the ledger's Slot seat. | +| `tool.call.images` Slot | The `read_image` tool-view registration declares it; `ToolRow` renders the result image. | +| `conversation.images.content` Factory | The attachment plugin registers this root-scoped gallery, implemented by `MessageImagesContent`. | + +The attachment plugin fills all four image Slots with the same `MessageImages` React adapter at priority 100. A smaller priority replaces only the selected domain's entry; disposing that replacement restores the official entry. Each adapter invokes `conversation.images.content` with explicit image references, `loadImage`, and layout parameters. The shared Factory neither declares the domain Slots nor selects a domain from a scene parameter. + +The image callers use their declared Slot directly; no image-entry Factory exists solely to forward one Slot call. User text remains a separate shared `conversation.user.text` Factory with its own keyed node Slot, as specified by the [user-text decision](2026-10-10-user-text-json-nodes.md). + +`AssistantNodeView` passes its Slot seat and `loadImage` to `AssistantMarkdown`. User wrappers pass the loader through `UserStyleBubble` to `UserContent`. Trajectory passes its Slot seat and loader through the ledger. These paths preserve component responsibilities, image order, and keys without custom ReactNode-producing image callbacks. + +Conversation owns read authorization, per-Session URL caching, and cleanup. The shared gallery never searches for a global current Session: persistent reads use the loader supplied by the caller, while submission-echo previews retain their existing browser URLs. Gallery layout, retries, thumbnails, and lightbox behavior remain in the attachment package. + +`ChatView` keeps the existing `MarkdownDelegateProvider` with its wrapped file opener, external-link action, and file-image capabilities. Native-image composition does not replace Markdown image handling or its failure feedback. + +`MessageImagesContentInput` accepts optional complete `MessageImageLabels`. Supplied labels are used unchanged; when omitted, the Content Factory's locale seat supplies the default copy. + +## Alternatives considered + +**One shared image Slot for every caller.** A replacement intended for one domain would also affect the others; shared implementation does not require shared replacement identity. + +**Import the gallery component across feature packages.** This bypasses standard Slot replacement and the reusable Factory entry point. + +**Pass custom image-rendering functions or supply the loader through a private Context.** Framework Slot seats already express rendering authority, while explicit loader props preserve the caller's Session authority. + +## Verification + +- User and output images use separate Slots; replacing either leaves the other and its mounted gallery untouched. +- Each official image entry has priority 100; a priority-0 replacement wins and disposal restores the official gallery. +- All four defaults call the same Content Factory with their original image data, authorized loader, and layout choices. +- Source order, keys, cached previews, failed-load retries, and lightbox interactions retain their existing behavior. +- The Content Factory accepts complete custom labels and resolves default labels when the caller omits them. +- Unloading the attachment implementation removes its entries without removing Slots declared by the owning domains. + +## Consequences + +Independent Slots allow domain-specific presentation without copying the gallery. Domain callers must forward the framework Slot seat to their plain React descendants and must supply an authorized loader at each image call. A Factory and an ordinary Slot have different lifetimes and ownership; a reusable implementation does not own every position where it appears. diff --git a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.zh.md b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.zh.md new file mode 100644 index 0000000000..03869f2dbb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.zh.md @@ -0,0 +1,54 @@ +# Agent Note: 领域图片 Slot 与共享画廊 Factory + +Status: implemented + +[English](2026-10-09-message-node-factories-and-delegates.md) | 中文 + +## 问题 + +用户消息、助手输出、Trajectory 记录和工具结果需要可独立替换的图片展示。官方条目可以共享同一画廊,但共享实现不能合并各自的替换位置,也不能隐藏调用方的图片读取授权。 + +## 决策 + +各领域声明自己的图片 Slot,并把框架 `renderSlot` 席位交给实际渲染它的组件。官方附件条目复用一个 Content Factory,沿用 [Body Slot 与 Content Factory 的分工](2026-10-09-reasoning-content-slot-factory.zh.md)。 + +| 入口 | 声明所有者与渲染组件 | +| --- | --- | +| `conversation.chat.user.images` Slot | `conversation.chat.user.content` Factory 声明,由 `UserContent` 为用户消息、待处理 steering 和本地回显渲染。 | +| `conversation.chat.message.images` Slot | Assistant 节点注册声明,由 `AssistantMarkdown` 渲染连续的输出图片组。 | +| `conversation.trajectory.images` Slot | Trajectory 视图注册声明,记录附件及工具输出块通过账本的 Slot 席位渲染。 | +| `tool.call.images` Slot | `read_image` 工具视图注册声明,由 `ToolRow` 渲染结果图片。 | +| `conversation.images.content` Factory | 附件插件注册此 root 作用域画廊,由 `MessageImagesContent` 实现。 | + +附件插件以优先级 100 为四个图片 Slot 填入同一个 `MessageImages` React 适配组件。较小的优先级只替换选定领域的条目;替换条目释放后恢复官方条目。适配组件显式向 `conversation.images.content` 传入图片引用、`loadImage` 和布局参数。共享 Factory 不声明各领域的 Slot,也不通过场景参数选择领域。 + +图片调用方直接使用其声明的 Slot,不设置仅转发一次 Slot 调用的图片入口 Factory。用户文本仍使用独立共享的 `conversation.user.text` Factory 及其 keyed 节点 Slot,详见[用户文本决策](2026-10-10-user-text-json-nodes.zh.md)。 + +`AssistantNodeView` 将 Slot 席位与 `loadImage` 传给 `AssistantMarkdown`。用户包装层通过 `UserStyleBubble` 将 loader 传给 `UserContent`。Trajectory 通过账本传递自己的 Slot 席位与 loader。这些路径保持组件职责、图片顺序和 key,不使用自制的 ReactNode 图片渲染回调。 + +Conversation 拥有读取授权、逐 Session URL 缓存与回收。共享画廊不查找全局当前 Session:持久图片使用调用方提供的 loader 读取,本地提交回显预览保留已有浏览器 URL。画廊布局、重试、缩略图和灯箱行为仍由附件包负责。 + +`ChatView` 保留既有 `MarkdownDelegateProvider` 及其包装后的文件打开动作、外链动作和文件图片能力。原生图片组合不替代 Markdown 图片处理及失败反馈。 + +`MessageImagesContentInput` 接受可选的完整 `MessageImageLabels`。提供 labels 时原样使用,省略时由 Content Factory 的 locale 席位提供默认文案。 + +## 考虑过的替代方案 + +**所有调用方共用一个图片 Slot。** 面向某个领域的替换也会影响其他领域;共享实现不要求共享替换身份。 + +**跨功能包导入画廊组件。** 绕过标准 Slot 替换机制和可复用的 Factory 入口。 + +**传递自制图片渲染函数,或通过私有 Context 供应 loader。** 框架 Slot 席位已经表达渲染权限,显式 loader props 则保留调用方的 Session 授权。 + +## 验证 + +- 用户与输出图片使用独立 Slot;替换任意一方时,另一方及其已挂载画廊不受影响。 +- 各官方图片条目优先级为 100;优先级 0 的替换条目胜出,释放后恢复官方画廊。 +- 四个默认条目携带原有图片数据、授权 loader 和布局选项调用同一个 Content Factory。 +- 来源顺序、key、缓存预览、读取失败重试及灯箱交互保持既有行为。 +- Content Factory 接受完整自定义 labels,调用方省略时解析默认 labels。 +- 卸载附件实现会移除其条目,但不移除各领域声明的 Slot。 + +## 影响 + +独立 Slot 支持各领域定制展示而无需复制画廊。领域调用方需要将框架 Slot 席位传给普通 React 后代组件,并在每次图片调用中提供授权 loader。Factory 与普通 Slot 的生命周期和所有权不同;可复用实现不拥有它出现的所有位置。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.i18n.yaml new file mode 100644 index 0000000000..da9d7f6c81 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-09-ptc-owned-policy-cleanup.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.md +/agent-note-ptc-owns-policy-change-cleanup-waits: + en: dfe28bee0ab79ff6 + zh: dd68c51b4cbfbc3f +/agent-note-ptc-owns-policy-change-cleanup-waits/problem: + en: 5620ff525d762114 + zh: ddef3a3624cab79b +/agent-note-ptc-owns-policy-change-cleanup-waits/decision: + en: 4389c330d95f1588 + zh: 97e4b9cf8b69f48e +/agent-note-ptc-owns-policy-change-cleanup-waits/alternatives-considered: + en: a51d7f5c5b8fd7dc + zh: 4480107a4d8cff70 +/agent-note-ptc-owns-policy-change-cleanup-waits/consequences: + en: f83009b6406bafa1 + zh: c774eb1895bf37e1 diff --git a/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.md b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.md new file mode 100644 index 0000000000..470568cc3f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.md @@ -0,0 +1,27 @@ +# Agent Note: PTC owns policy-change cleanup waits + +Status: implemented + +English | [中文](2026-10-09-ptc-owned-policy-cleanup.zh.md) + +## Problem + +Stopping a retained Node process and its nested tools can take longer than recording a new file policy. Making permission setters await that cleanup spreads PTC lifecycle requirements into unrelated callers, including Webhook Session creation, and adds cancellation windows to those callers. + +## Decision + +Permission setters commit synchronously. Success means the selected policy is recorded; it does not guarantee that operations admitted under the old policy have completely stopped. This replaces the completion requirement and cleanup ownership in the [retained-process sandbox decision](2026-10-08-persistent-ptc-sandbox.md), whose confinement and one-time escalation rules still apply. + +PTC observes committed Session mode changes. The tool bridge immediately cancels affected complete calls, including pending approvals and nested tools. A later PTC call waits for canceled predecessors to settle before resolving the latest standing policy. The Node provider immediately invalidates affected processes, including idle environments, starts cleanup, and retains the cleanup Promise internally. A later program waits for that cleanup; failed cleanup remains retryable and prevents process reuse. Unchanged modes preserve environments. Tool-registry and provider unload remove their observers and await owned work. + +## Alternatives considered + +**Wait in permission setters.** This provides a definite completion point for old-authority work, but no current permission consumer requires that guarantee. It introduces a general resource registry and asynchronous API migration for PTC-specific cleanup. The completion guarantee belongs to PTC execution and disposal instead. + +**Clean up only on the next PTC call.** An idle environment can retain background work indefinitely when no further call occurs. Policy changes begin cancellation immediately; only waiting is deferred. + +**Reconfigure an existing process.** The sandbox applies restrictions at launch. Open handles, retained objects and background work require releasing the old environment before another policy can execute there. + +## Consequences + +Permission changes return without process-cleanup latency. Old operations may remain active while cancellation finishes, and cleanup failure does not roll back the recorded policy. Subsequent PTC execution waits for cleanup and uses the latest policy. Webhook creation keeps its synchronous permission step, so that step adds no cancellation window before prompt admission. Regression coverage holds cleanup with explicit barriers, changes policy again while a call waits, checks idle cancellation without another call, and verifies cleanup retry and owner unload. diff --git a/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.zh.md b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.zh.md new file mode 100644 index 0000000000..9d852a0eb6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.zh.md @@ -0,0 +1,27 @@ +# Agent Note: PTC 自行等待策略变化后的清理 + +Status: implemented + +[English](2026-10-09-ptc-owned-policy-cleanup.md) | 中文 + +## 问题 + +停止保留的 Node 进程及其嵌套工具,可能比记录新文件策略耗时更长。让权限设置等待这些清理,会把 PTC 生命周期要求传给 Webhook Session 创建等无关调用方,并为这些调用方增加取消窗口。 + +## 决策 + +权限设置同步提交。成功表示所选策略已经记录,不保证按旧策略接纳的操作已完全停止。这替代[保留进程沙箱决策](2026-10-08-persistent-ptc-sandbox.zh.md)中的完成要求和清理归属;其中的沙箱限制与单次提权规则继续适用。 + +PTC 观察已提交的 Session 模式变化。工具桥接立即取消受影响的完整调用,包括等待中的审批和嵌套工具。后续 PTC 调用先等待已取消的调用结束,再解析最新常设策略。Node 提供方立即使受影响的进程失效,包括空闲环境,开始清理并在内部保留清理 Promise。后续程序等待清理完成;清理失败可重试,且阻止复用进程。模式不变时保留环境。工具注册表和提供方卸载时移除各自监听器,并等待所拥有的工作结束。 + +## 考虑过的替代方案 + +**在权限设置中等待。** 这可以明确旧权限下的工作完成时点,但当前权限调用方不需要该保证。为此增加通用资源登记和异步 API 迁移,会扩大 PTC 清理的影响范围。完成保证由 PTC 执行和销毁负责。 + +**等下次 PTC 调用再清理。** 没有后续调用时,空闲环境的后台工作可能一直存在。策略变化立即开始取消,只有等待被推迟。 + +**重新配置已有进程。** 沙箱在启动时应用限制。打开的句柄、保留对象和后台工作要求先释放旧环境,才能在其中按另一策略执行。 + +## 影响 + +权限变化返回时无需等待进程清理。取消完成前,旧操作可能仍在运行;清理失败不会回滚已记录的策略。后续 PTC 执行等待清理并使用最新策略。Webhook 创建保留同步权限步骤,该步骤不会在提交任务前增加取消窗口。回归测试通过显式信号阻塞清理,覆盖等待期间再次改变策略、没有后续调用时取消空闲环境、清理重试及所有者卸载。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.i18n.yaml new file mode 100644 index 0000000000..ba86de4f69 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-09-reasoning-content-slot-factory.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.md +/agent-note-standard-reasoning-body-slot-and-reusable-content-factory: + en: 55862b96f6731c81 + zh: 425ab1ff7043a606 +/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/problem: + en: 91000d9dd4a47647 + zh: 6146424ac5c3987b +/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/decision: + en: 0a45affd1b125113 + zh: 3af28a27b7721bc7 +/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/decision/registration-and-rendering: + en: 0af348d91eedc16e + zh: a06d6f59cc271e94 +/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/decision/data-state-and-lifecycle: + en: e220ebf28a113e0b + zh: dfedd85ac5d76ccb +/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/decision/translation-plugin-integration: + en: 145a1ae617a49f43 + zh: b2eb8443bb1515a7 +/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/alternatives-considered: + en: 89adfc6fbad716f3 + zh: 098cca9e73c5eeaf +/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/verification: + en: 23e6e98d176f08f4 + zh: 2b89720b8914552b +/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/consequences: + en: 60a32f46ff947154 + zh: 6498e791d4c2c166 diff --git a/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.md b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.md new file mode 100644 index 0000000000..b1c3f5c7d6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.md @@ -0,0 +1,102 @@ +# Agent Note: Standard reasoning Body Slot and reusable Content Factory + +Status: implemented + +English | [中文](2026-10-09-reasoning-content-slot-factory.zh.md) + +## Problem + +Reasoning bodies need to let third parties replace or wrap their presentation while reusing the official Markdown renderer, localized labels, and compact typography. An optional renderer parameter that makes the host choose between direct rendering and a chain fallback leaves the default body outside the registration system and forces plugins to maintain another Markdown call. + +The render position and the base implementation serve different purposes: the position accepts plugin registrations and lets the framework select one, while any component can call the implementation directly. Calling the base implementation must not repeat plugin selection for that position or require a Session. + +## Decision + +The design uses existing [Component Factories](2026-09-10-component-factories-and-local-slots.md) and ordinary Slots without adding framework dispatch machinery. + +| Name | Type | Owner and responsibility | +| --- | --- | --- | +| `conversation.chat.reasoning.body` | `single`, `session` Slot | Body position declared by the Assistant node registration; receives only original text and streaming state when expanded. | +| `conversation.chat.reasoning.content` | `root` Factory | Standard reasoning content provided by `ui-chat`; accepts text, streaming state, and optional labels, supplies default labels, and fixes `variant="compact"`. | + +The Factory receives no Session identity, reads no Session, and owns no translation state. The current Factory API requires `scope`; `root` expresses the absence of a Session dependency, while each render position still has an independent React instance. The Chat plugin registers the Factory independently of any Body occupant, so replacing a body does not remove the base implementation. + +### Registration and rendering + +1. `ui-chat` registers the Content Factory with component props derived through `FactoryComponentPropsOf`. When the caller omits labels, the Factory constructs stable defaults from Chat's locale seat. +2. The Assistant node registration declares the Body Slot. An expanded `ReasoningRow` only calls `renderSlot`; it accepts no optional renderer, supplies no inline fallback, and does not check whether a plugin is enabled. +3. The official default Body is an ordinary registration with `priority: 100`. It forwards only `text` and `running` to the Content Factory. +4. Third parties wait for the Body declaration through `slots.inject` and register their Body with a smaller priority value. They may call the Content Factory directly and add their own controls around it without importing private official React components. +5. When a third party unregisters, the framework selects the official default registration again. Multiple third-party registrations follow existing single-slot priorities; they do not form middleware or render multiple bodies together. + +```text +ReasoningRow + └─ conversation.chat.reasoning.body + ├─ 官方默认 Body ───────────┐ + └─ 第三方 Body → 自有控件 ─┤ + └─ conversation.chat.reasoning.content + └─ MarkdownText +``` + +The body render position: + +```tsx ignore-check +
+ {renderSlot('conversation.chat.reasoning.body', { text, running })} +
+``` + +The default Body and third-party wrappers use the same reusable implementation; a third party passes labels when it needs custom copy: + +```tsx ignore-check +renderFactorySlot('conversation.chat.reasoning.content', { + text: displayText, + running, + labels, +}) +``` + +### Data, state, and lifecycle + +- `ReasoningBodyOwnerProps` contains only readonly `text: string` and `running: boolean`. `ReasoningContentInput` also accepts `labels?: MarkdownLabels`; supplied labels are used unchanged without field-level merging, and omission selects the official defaults. Callers own the localization and reference stability of custom labels. +- The Body's `text` is always the original reasoning. A third party selects original or derived display text and passes that value to the Factory without rewriting the Session or Chat projection. +- `ReasoningRow` retains its existing disclosure state, summary, shimmer, and title behavior. Plugin-specific selection state, buttons, and styles stay inside the plugin's Body wrapper; the official implementation supplies no `setHeaderAction`, header-action type, or corresponding state. +- Registrations follow Cordis effect lifetimes. Collapsing the Body declaration removes its contributions through `slots.inject`; unloading the Chat plugin removes the Content Factory. +- Factory components and the default Body have stable module-level identities. Text changes update props instead of rebuilding component types or keys. Plugin replacement or HMR may remount components according to existing framework semantics; unmounted components have no state-retention guarantee. + +### Translation plugin integration + +The [anonymous translation design](2026-10-05-anonymous-reasoning-translation.md) retains its external-request, privacy, failure, and cancellation decisions; this design supersedes its body composition mechanism. The translation plugin registers in the standard Body Slot, renders original/translation selection, status, and retry controls in its own toolbar, and wraps the official Content Factory with its own state and optional custom labels rather than calling `MarkdownText` directly. + +Translation requests, splitting, caching, persistence, and settings remain unchanged. The translation toggle belongs to the plugin body rather than the official Think title. The design adds no Slot outside the body, does not handle user-message masking, and does not change the general Markdown parser. + +## Alternatives considered + +**Translation-specific chain and host fallback.** The default content is not an ordinary registration, the host maintains a second render path, and third parties cannot directly reuse the complete official body implementation. + +**Expose only the Body Slot.** This permits replacement, but wrapping plugins still have to copy the official Markdown call, labels, and variant. + +**Provide only the Content Factory.** This permits implementation reuse but supplies no actual body position for third-party replacement registrations. + +**Export React components across plugins.** This bypasses existing Factory loading, lifecycle, and type derivation and adds feature-plugin runtime dependencies. + +**Name the Factory as general Markdown.** This implementation fixes reasoning-specific compact typography and supplies Chat defaults; it is not a general entry point for all Markdown uses. + +**Make the official implementation maintain plugin header actions.** The default body has no such requirement; a dedicated setter makes the host own plugin state, cleanup, and button styles. + +**Force official labels in the Factory.** Default copy must not prevent caller customization; an optional parameter provides an override without replacing the whole body implementation. + +## Verification + +- The body host only calls `conversation.chat.reasoning.body`, with no old `reasoning-body` chain, optional renderer, or inline Markdown fallback. +- Both the default Body and translation Body render through `conversation.chat.reasoning.content`; omitted labels use official defaults, and custom labels reach Markdown unchanged. +- The Factory works without a Session Provider, and two render positions do not share component-local state. +- The official implementation has no header-action type, setter, state, or dedicated styles; the translation wrapper supplies its own selection and retry controls. +- Enabling, disabling, or hot-reloading the translation plugin selects the corresponding Body; default output, original-text access, streaming, disclosure, and cancellation behavior remain consistent. +- Unit and assembly tests cover registration disposal, a Session-independent Factory, default and custom labels, shared implementation reuse, and independent occurrences; existing browser replay covers default bodies and translation switching. + +## Consequences + +The old Slot name, chain-selector registrations, and header-action callback are unsupported, so third parties must migrate. Test fixtures and the Client inspect catalog must follow the new declarations rather than offer obsolete registration examples. + +The Body remains an exclusive replacement position. This design does not automatically compose multiple text-transformation plugins or turn display transformations into redaction or model-input modification. diff --git a/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.zh.md b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.zh.md new file mode 100644 index 0000000000..73986249ef --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.zh.md @@ -0,0 +1,102 @@ +# Agent Note: 思考正文的标准 Slot 与可复用 Content Factory + +Status: implemented + +[English](2026-10-09-reasoning-content-slot-factory.md) | 中文 + +## 问题 + +思考正文需要允许第三方替换或包装展示,同时复用官方的 Markdown、本地化文案和紧凑排版。将扩展做成可选的 renderer 参数,再由宿主选择直接渲染或 chain fallback,会让默认正文存在于注册体系之外,插件也只能另行维护 Markdown 调用。 + +渲染位置与基础实现是两种不同的能力:位置允许多个插件注册并由框架选中一个,实现则应允许任何组件直接调用。调用基础实现不应再次触发该位置的插件选择,也不应要求 Session。 + +## 决策 + +沿用现有 [Component Factory](2026-09-10-component-factories-and-local-slots.zh.md) 和普通 Slot,不增加框架分发机制。 + +| 名称 | 类型 | 所有者与职责 | +| --- | --- | --- | +| `conversation.chat.reasoning.body` | `single`、`session` Slot | Assistant 节点注册声明的正文位置;展开时只接收原始文本与流式状态。 | +| `conversation.chat.reasoning.content` | `root` Factory | `ui-chat` 提供的标准思考正文;接收文本、流式状态及可选 labels,提供默认文案并固定 `variant="compact"`。 | + +Factory 不接收 Session 身份,不读取 Session,不持有翻译状态。现有 Factory API 要求填写 `scope`,使用 `root` 表达无 Session 依赖;每个调用位置仍有独立的 React 实例。Factory 由 Chat 插件注册,而非依附于某个 Body occupant,第三方替换正文不会撤掉基础实现。 + +### 注册与调用 + +1. `ui-chat` 注册 Content Factory,组件 props 由 `FactoryComponentPropsOf` 推导。调用者未传 labels 时,Factory 使用 Chat 的 locale seat 构造稳定的默认文案。 +2. Assistant 节点注册声明 Body Slot。`ReasoningRow` 展开时只调用 `renderSlot`,不接收可选 renderer,不提供内联 fallback,也不判断插件是否启用。 +3. 官方以 `priority: 100` 注册普通默认 Body。该组件只将 `text`、`running` 传给 Content Factory。 +4. 第三方通过 `slots.inject` 等待 Body 声明,以较小的 priority 注册自己的 Body。第三方可直接调用 Content Factory,并在外层增加自己的控件;无需导入官方私有 React 组件。 +5. 第三方注销后,框架重新选中官方默认注册项。多个第三方注册遵循现有 single 优先级,不形成 middleware,也不同时渲染多份正文。 + +```text +ReasoningRow + └─ conversation.chat.reasoning.body + ├─ 官方默认 Body ───────────┐ + └─ 第三方 Body → 自有控件 ─┤ + └─ conversation.chat.reasoning.content + └─ MarkdownText +``` + +正文调用位置: + +```tsx ignore-check +
+ {renderSlot('conversation.chat.reasoning.body', { text, running })} +
+``` + +默认 Body 与第三方包装都使用同一个可复用实现;第三方需要自定义文案时传入 labels: + +```tsx ignore-check +renderFactorySlot('conversation.chat.reasoning.content', { + text: displayText, + running, + labels, +}) +``` + +### 数据、状态与生命周期 + +- `ReasoningBodyOwnerProps` 仅包含只读的 `text: string` 与 `running: boolean`。`ReasoningContentInput` 另外允许 `labels?: MarkdownLabels`;传入的文案原样使用,不做字段级合并,省略时才使用官方默认文案。调用者负责自定义文案的本地化与引用稳定性。 +- Body 的 `text` 始终是原始思考。第三方选择原文或派生显示文本后,只将该值交给 Factory,不改写 Session 或 Chat projection。 +- 折叠状态、摘要、流光和标题由 `ReasoningRow` 保持现有行为。插件自己的切换状态、按钮及样式留在其 Body 包装中;官方不提供 `setHeaderAction`、标题动作类型或相应状态。 +- 注册沿用 Cordis effect 生命周期。Body 声明撤销时,其贡献随 `slots.inject` 清理;Content Factory 在 Chat 插件卸载时撤销。 +- Factory 组件与默认 Body 使用模块级稳定身份,文本变化作为 props 更新,不以文本重建组件类型或 key。插件替换或 HMR 可按现有框架语义重新挂载,不承诺保留被卸载组件的本地状态。 + +### 翻译插件接入 + +[免登录翻译设计](2026-10-05-anonymous-reasoning-translation.zh.md)保留外部请求、隐私、失败与取消决策;其正文组合方式由本方案取代。翻译插件注册到标准 Body Slot,在自己的工具栏内渲染原文/译文切换、状态及重试按钮,使用自己的状态和可选自定义 labels 包装官方 Content Factory,不直接调用 `MarkdownText`。 + +翻译请求、分片、缓存、持久化和设置保持不变。翻译切换按钮属于插件正文,不再由官方思考标题承载;不增加正文外的 Slot,不处理用户消息遮挡,也不改动通用 Markdown 解析器。 + +## 考虑过的替代方案 + +**翻译专用 chain 与宿主 fallback。** 默认内容不是普通注册项,宿主需要维护第二条渲染路径,第三方无法直接复用完整的官方正文实现。 + +**只开放 Body Slot。** 可以替换正文,但第三方包装时仍需复制官方 Markdown 调用、labels 与 variant。 + +**只提供 Content Factory。** 可以复用实现,但缺少允许第三方注册替换的实际正文位置。 + +**跨插件导出 React 组件。** 绕过现有 Factory 的加载、生命周期与类型推导,增加 feature-plugin 运行时依赖。 + +**将 Factory 命名为通用 Markdown。** 该实现固定思考正文的紧凑排版,并提供 Chat 默认文案,不是所有 Markdown 场景的通用入口。 + +**官方替插件维护标题动作。** 官方默认正文没有该需求;保留专用 setter 会让宿主承担插件的状态、清理与按钮样式。 + +**Factory 强制使用官方 labels。** 默认文案不应阻止调用者定制;可选参数即可表达覆盖,无需替换整套正文实现。 + +## 验证 + +- 正文宿主只调用 `conversation.chat.reasoning.body`,没有旧 `reasoning-body` chain、可选 renderer 或内联 Markdown fallback。 +- 官方默认 Body 和翻译 Body 都通过 `conversation.chat.reasoning.content` 渲染;未传 labels 使用官方默认文案,传入自定义 labels 则原样交给 Markdown。 +- Factory 在没有 Session Provider 的调用位置也能使用;两个调用位置不共享组件本地状态。 +- 官方不存在标题动作类型、setter、状态或专用样式;翻译包装自行提供切换与重试控件。 +- 翻译插件启用、停用及 HMR 后,框架选中对应 Body;默认输出、原文访问、流式展示、折叠和取消行为保持一致。 +- 单元与装配测试覆盖注册撤销、无 Session Factory、labels 缺省与覆盖、默认与第三方共用实现及双实例;已有浏览器回放覆盖默认正文和翻译切换。 + +## 后果 + +旧 Slot 名称、chain selector 注册及标题动作回调不再受支持,第三方需要迁移。测试夹具与 Client inspect catalog 必须同步到新声明,避免仍向插件开发者提供旧注册方式。 + +Body 仍是独占替换位置。此方案不解决多个正文转换插件的自动组合,也不将显示变换提升为脱敏或模型输入修改能力。 diff --git a/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.i18n.yaml new file mode 100644 index 0000000000..41d9dfe377 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for 2026-10-10-user-text-json-nodes.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh: + en: 6185ac67a459beba + zh: 994bf90f05c0fd5a +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/problem: + en: 42e3752efebedde5 + zh: 75e23c5762efbb1f +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision: + en: 71f5b33f58cd2066 + zh: 8365506795c0b158 +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/rule-roles-and-original-coordinates: + en: ed0fa9306f0605cb + zh: 7240e135f6d1b46b +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/one-reconciliation-loop: + en: acaeb2dccfac3f07 + zh: 2170ec2b858627fc +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/query-cache-and-traversal: + en: 55dee64de32bef9a + zh: 69eb625451f55268 +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/actual-facts-dependencies: + en: 11bc718324d22c03 + zh: 371576c1b2a90d62 +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/facts-text-and-registration-refresh: + en: 47bc3f32b9aa22de + zh: 59ffd06c800c00af +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/output-ownership-and-publication: + en: e076cb57a1fdcfc1 + zh: 988c02c6cf751f16 +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/verification-requirements: + en: 78127c1f18eacf8a + zh: fcc2ad5db1279356 +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/alternatives-considered: + en: 439e5ecff6701938 + zh: 9861805f2a19439c +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/consequences: + en: 13bd26532d0353fa + zh: d6a4997b51af09d5 diff --git a/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md new file mode 100644 index 0000000000..3b01c313f0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md @@ -0,0 +1,91 @@ +# Agent Note: User-text candidate scheduling and Facts-driven refresh + +Status: implemented + +English | [中文](<2026-10-10-user-text-json-nodes.zh.md>) + +## Problem + +User text contains references owned by independent businesses. Recognition must follow original-text positions, resolve collisions deterministically, and reuse unaffected matching work when Facts change. The evaluator must expose its scheduling behavior to pure logic tests without depending on React, browser timing, or elapsed-time benchmarks. + +## Decision + +The composition is business registration, lazy candidate matching, a single reconciliation loop, readonly JSON nodes, and business-owned keyed renderers. Initialization and Facts refresh use the same algorithm; they differ only in which query caches remain valid. + +### Rule roles and original coordinates + +Every rule provides an ID, finite order, and synchronous `match(text, from, context)`. Each call returns its nearest eligible node with `start >= from`, or no candidate anywhere in the remaining original text. It returns one node, not all matches or a replacement document. The original string and UTF-16 offsets remain intact: a search cursor does not create a new word, line, or quoted-token boundary. Matching depends only on original text and Facts, not previously selected node payloads. + +Each matcher resolves its own alternative spellings by earliest start and then its documented same-start choice; Session labels choose the longer match. A matcher cannot replace a consumed prefix with the remainder of an overlapping candidate. Each business owns its grammar, payload, and renderer; the generic evaluator has no Session, file, skill, or command branches. + +There is one rule type and one candidate protocol. A candidate contains either a business node using the rule ID or a Text node preserving the original substring with an optional `data.display`. Text-producing rules compete by the same start, order, and stable ID as business rules. Order only breaks same-start ties; no rule gets precedence over an earlier position. + +### One reconciliation loop + +The evaluator keeps original text, the previous successful segmentation, registered definitions, per-rule Facts reads, and query results. Nonempty text initially has one Text covering `[0, text.length)`; empty text has no nodes. Initialization makes every rule invalid, including rules that read no Facts. + +Each reconciliation starts its result cursor at zero. Every matcher obtains one candidate through the same cache-aware query operation. The evaluator selects the smallest candidate start, breaks same-start ties by ascending rule order and stable ID, emits the preceding raw Text gap, emits the winner, and advances the cursor to the winner's end. No candidate means the remaining raw text is emitted and recognition ends. + +Only a consumed candidate or a candidate whose start is before the new cursor needs another query. A cached candidate beginning at or after the cursor remains eligible. A no-candidate result remains eligible as the cursor advances. All ranges are nonempty half-open original-text ranges; candidates cannot move the cursor backward or exceed the original string. + +Previously accepted nodes are reusable output, not a complete list of possible matches: a losing candidate can become visible when an earlier winner disappears. The evaluator retains queries for this purpose instead of treating an old Text node or the absence of a business node as unconditional evidence of no match. + +### Query cache and traversal + +A query record contains its original `from` offset and either the next candidate, no candidate, or a failure. If a query from `q` returned a candidate starting at `s`, its result is valid for any requested cursor `c` with `q <= c <= s`. A no-candidate result is valid for every `c >= q`. Neither result proves anything before `q`. A failed query is reusable only at the same requested offset while its dependencies remain valid. + +During a reconciliation, each matcher has one active candidate. The evaluator also retains the sorted query records already produced for that matcher across reconciliations; it does not enumerate all possible matches eagerly. Forward cursors walk these records monotonically. Queries generated in newly exposed gaps are merged with retained records in source order, with one record per query offset. Unsearched intervals require matching rather than an invented negative result. + +The result cursor and matcher search cursors are distinct. Starting result reconciliation at zero does not require unchanged matchers to scan from zero again: their recorded queries answer valid requests. Each matcher must implement a forward search rather than repeatedly materializing every match in the remaining text. Internal scans, candidate selection, and result comparison still have costs; the scheduling guarantees concern actual matcher calls, not a claim that arbitrary regular expressions are linear. + +### Actual Facts dependencies + +`context.read(key)` returns the borrowed readonly value from the current Facts snapshot and records the read, including `undefined`. The reader is valid only during its owning synchronous call and cannot be retained. Businesses receive neither a whole Facts object, a manual `dependsOn` list, nor previous-output/cache objects. + +A matcher's dependency set is the union of reads for the queries retained in its current cache generation, including losing candidates, no-match queries, and failed calls. Changed fields are detected with `Object.is` at field granularity. A new outer Facts object alone does not invalidate matching. When a rule is invalidated, its old queries and read set are discarded; subsequent calls establish the replacement read set, so obsolete conditional dependencies do not remain permanently attached. + +Text-producing rules use the same query cache and dependency generation as every other rule. Updating a whitespace switch invalidates only its readers; it does not trigger an unconditional pass over the completed nodes. + +### Facts, text, and registration refresh + +A Facts update invalidates only rules that read changed fields. Their first query starts at original offset zero; all other rules retain their query records. The same reconciliation loop then compares fresh candidates with reusable ones. Refreshing a rule does not give it priority, and a new first candidate does not hide the removal of an earlier old winner. + +A fresh candidate confined to previously plain text can split that Text without invoking unrelated matchers whose cached queries still cover the new cursors. A fresh candidate that loses to an earlier unchanged winner is discarded without invalidating that winner. When a fresh winner covers another candidate, only expired heads advance. When a winner disappears or shrinks, cached losing candidates can compete again; any previously unsearched interval causes a query at the newly exposed cursor. All changes use the same cursor loop and query-coverage rules. + +Several changed Facts fields form one snapshot and one invalidation set. All affected matchers participate in the same reconciliation; one update is not committed before processing the next. Equivalent refreshed results reuse previous nodes and, when all elements remain identical, the entire result array. Reconciliation settles the complete document before publishing its result. + +Original-text changes discard all query caches and supply one full-range Text as the previous segmentation. New or replaced rule definitions start with empty caches; unchanged definitions retain their independent queries. Removed rules lose their dependencies and candidates. Reconciliation after additions, removals, or order changes uses the same selection logic and can expose formerly blocked candidates. + +### Output, ownership, and publication + +Nodes retain `{ id, start, end, data }`. Business nodes use their matcher's ID; Text retains the exact source substring in `data.text` and optional display text in `data.display`. Output is ordered, nonoverlapping, and completely covers the original text. JSON compatibility and readonly ownership are TypeScript obligations: the evaluator neither freezes borrowed objects nor deep-copies Facts or payloads for isolation. + +The document evaluator owns all caches; the registry retains neither evaluators nor document subscriptions. Separate text instances do not share state through a global cache. Only fully successful evaluation publishes a new result. A matcher failure carries the rule ID and cause and publishes no partial segmentation; its actual reads remain available for dependency-driven retry. Reentrant evaluation and escaped synchronous read contexts are rejected. + +`conversation.user.text` remains a session-scoped Factory owning `conversation.user.text.node`, a keyed inline Slot. React consumes final nodes and forwards `raw` and `interactive` to the business renderer. Session, skill, file/folder, and command matching remain owned by their respective plugins, and actions remain in each renderer's Injection. No matching work or cache subscription moves into React. + +`collapseWhitespace` and `trimWhitespace` each control one ordinary matcher and default to off. Collapse offers whitespace runs as Text candidates with display `" "`; trim offers original leading/trailing whitespace runs with display `""`. Trim has lower order than collapse, so it wins their same-start edge collisions. An earlier reference candidate consumes its whole range and naturally excludes internal whitespace candidates. Every Text candidate retains its exact raw substring; emoji and spaces between references remain intact. `interactive` and CSS `ellipsis` remain React Props, not matching Facts. Queue uses full original text with CSS ellipsis; native attachments remain in their existing cards and image Slots, outside text recognition. + +## Verification requirements + +Pure logic tests assert both the final nodes and each matcher's ordered calls with their `from` offsets. They use deterministic input and spies rather than clocks or performance thresholds. + +- Initialization calls every matcher, including Facts-free and no-match rules; empty input has explicit terminal-query behavior. +- Earlier starts beat stronger order values; same-start ties use order and stable ID, and each matcher preserves its own longest-match choice. +- A future candidate and a no-match answer are not requested again during forward traversal; only the winner and overlapping heads advance. +- Unread Facts changes and new outer Facts objects with unchanged field values cause no matcher calls. +- One or several changed fields rerun exactly their readers; missing fields, losing candidates, no-match results, failures, and conditional read-set replacement remain covered. +- Equivalent refreshes preserve node/array identity and do not invoke unrelated matchers; payload-only changes preserve unaffected nodes. +- Insertions in plain text, losing new candidates, same-start winner changes, extending/shrinking matches, and removal of an early winner assert the exact affected query offsets and counts. +- Removing or shrinking a winner exposes cached losers and unsearched gaps; an old tail no-match result cannot answer a query before its recorded origin. +- Batched Facts updates equal a fresh evaluation under the same snapshot and do not give the first refreshed matcher precedence. +- Text and rule-definition changes, registration/disposal, range validation, synchronous failure containment, Context lifetime, and independent evaluator state have deterministic tests. +- Real Session/file/skill/command combinations preserve full quoted references, original word boundaries, and same-start precedence; whitespace candidates use exactly the same scheduling and cache rules. + +## Alternatives considered + +**Ordered whole-document business transforms.** A business can consume later text before an earlier reference from another business is considered, so fixed type order does not express leftmost recognition. + +## Consequences + +Field-level invalidation can require a matcher to traverse the document again; it does not promise per-element Facts invalidation. Query history consumes memory proportional to the distinct recorded search positions for the current text and definitions. Reconciliation and JSON identity alignment can still traverse output without invoking matchers. The performance evidence is the exact scheduling count and query offsets, while semantic evidence compares refreshed output with a fresh evaluator. This mechanism requires no browser end-to-end or elapsed-time benchmark tests. diff --git a/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md new file mode 100644 index 0000000000..c689d56cc9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md @@ -0,0 +1,91 @@ +# Agent Note: 用户文本候选调度与 Facts 驱动刷新 + +Status: implemented + +[English](<2026-10-10-user-text-json-nodes.md>) | 中文 + +## 问题 + +用户文本包含独立业务拥有的引用。识别必须按原文位置推进,确定性地处理冲突,并在 Facts 变化时复用无关匹配工作。计算器必须能通过纯逻辑测试展示调度行为,不依赖 React、浏览器时序或耗时基准。 + +## 决策 + +采用业务注册、按需候选匹配、单一协调循环、只读 JSON 节点和业务拥有的 keyed renderer。初始化与 Facts 刷新使用同一个算法,只在查询缓存的有效性上存在差别。 + +### 规则职责与原始坐标 + +所有规则都提供 ID、有限的 order 和同步 `match(text, from, context)`。每次调用返回 `start >= from` 的最近有效节点,或确认剩余原文中没有候选。它返回一个节点,不返回全部匹配或整份替换文档。原始字符串及 UTF-16 坐标保持不变:搜索游标不创造新的词首、行首或带引号词元边界。匹配只依赖原文和 Facts,不依赖先前选中节点的 payload。 + +每个匹配器先按最早起点处理自身不同写法,再按该语法规定处理同起点选择;Session 标签选择较长匹配。匹配器不能把被消费前缀之后的半截重叠候选变成新匹配。语法、payload 和 renderer 均归对应业务所有,通用计算器不包含 Session、文件、skill 或命令分支。 + +只有一种规则类型和一个候选协议。候选既可以包含使用规则 ID 的业务节点,也可以包含保留原文子串、携带可选 `data.display` 的 Text 节点。产生 Text 的规则与业务规则按相同的起点、order 和稳定 ID 竞争。Order 仅裁定同起点冲突,任何规则都不优先于更早的位置。 + +### 单一协调循环 + +计算器保留原文、上一次成功分段、注册定义、逐规则 Facts 读取记录和查询结果。非空原文最初对应一个覆盖 `[0, text.length)` 的 Text,空原文没有节点。初始化使所有规则失效,包括不读取 Facts 的规则。 + +每次协调都从结果游标零开始,各匹配器通过同一个带缓存的查询操作获取一个候选。计算器选择起点最小的候选,同起点按规则 order 升序和稳定 ID 裁定,输出前面的原文 Text 空隙,输出赢家,再将游标推进到赢家末尾。全部没有候选时,输出剩余原文并结束识别。 + +只有被消费的候选或起点早于新游标的候选需要再次查询。缓存候选从新游标或其后开始时仍然有效;无候选结论在游标前进时保持有效。所有范围均是原文中的非空半开区间;候选不能使游标倒退或超出原文。 + +先前采纳的节点是可复用输出,不是所有可能匹配的完整清单;更早的赢家消失后,落败候选可能重新可见。计算器为此保留查询,而不能将旧 Text 节点或缺少某类业务节点无条件解释为没有匹配。 + +### 查询缓存与遍历 + +查询记录包含原始 `from` 偏移,以及下一个候选、无候选或失败。若从 `q` 查询得到起点为 `s` 的候选,则在请求游标 `c` 满足 `q <= c <= s` 时可以复用;无候选结论可用于所有 `c >= q`。两者都不能证明 `q` 之前的情况。失败查询只在请求偏移相同且依赖仍有效时复用。 + +一次协调过程中,每个匹配器只有一个活动候选。计算器同时保留该匹配器跨次协调已经产生的有序查询记录,不预先枚举所有可能匹配。前进游标单调遍历这些记录;新暴露空隙中的查询按原文顺序与旧记录合并,每个查询偏移只保留一条记录。未搜索区间必须执行匹配,不能虚构无匹配结论。 + +结果游标与匹配器搜索游标不同。结果协调从零开始,不代表无变化的匹配器需要从零重新扫描,它们的查询记录可以回答有效请求。各匹配器必须实现向前搜索,不能每次调用都重新收集剩余文本中的全部匹配。内部扫描、候选选择及结果比较仍有成本;调度保证针对实际匹配器调用,不宣称任意正则表达式都是线性的。 + +### 实际 Facts 依赖 + +`context.read(key)` 返回本次 Facts 快照中借用的只读值,并记录读取,包括 `undefined`。读取器仅在所属同步调用期间有效,不得保留。业务既不获得完整 Facts 对象,也不维护手工 `dependsOn` 列表或接收上一轮输出/缓存对象。 + +匹配器依赖集合是当前缓存代次所保留查询读取字段的并集,包括落败候选、无匹配查询和失败调用。字段变化按 `Object.is` 比较,外层 Facts 对象换引用不单独使匹配失效。规则失效时丢弃旧查询及读取集合,后续调用建立新的集合,因此已经不再读取的条件依赖不会永久残留。 + +产生 Text 的规则与其他规则共用查询缓存和依赖代次。空白开关更新只使它的读取者失效,不触发对已完成节点的无条件遍历。 + +### Facts、原文与注册刷新 + +Facts 更新只使实际读取变化字段的规则失效。它们的首次查询从原文零开始,其他规则保留查询记录;随后由同一个协调循环比较新候选和可复用候选。规则刷新不赋予额外优先权,新首个候选也不能掩盖更早旧赢家的消失。 + +新候选完全位于原有普通文字内时,可以拆分该 Text;无关匹配器的缓存仍覆盖新游标时,不调用它们。新候选输给更早的有效旧赢家时,丢弃新候选,不使旧赢家失效。新赢家覆盖其他候选时,只推进过期的候选头。赢家消失或缩短时,缓存的落败候选可以重新竞争;此前未搜索的区间在新暴露游标处触发查询。所有变化都使用同一个游标循环和查询覆盖规则。 + +多个 Facts 字段变化形成同一份快照和一个失效集合,所有受影响匹配器共同参与一次协调,不先提交某个更新再处理另一个。刷新结果等价时复用旧节点,所有元素相同则复用整个结果数组。协调在整份文档确定后发布结果。 + +原文变化会丢弃全部查询缓存,并以覆盖全文的 Text 作为上一版分段。新增或替换的规则定义从空缓存开始,无变化定义保留各自独立查询。规则移除时删除其依赖和候选。新增、移除或顺序变化后的协调仍使用相同选择逻辑,并可暴露此前被遮挡的候选。 + +### 输出、所有权与发布 + +节点保留 `{ id, start, end, data }`。业务节点使用所属匹配器 ID;Text 的 `data.text` 保留准确原文子串,`data.display` 可保存显示文字。输出有序、非重叠并完整覆盖原文。JSON 兼容性与只读所有权由 TypeScript 协议约束;计算器既不冻结借用对象,也不为隔离而深拷贝 Facts 或 payload。 + +文档计算器拥有全部缓存;注册表不持有计算器或文档订阅,不同文本实例不通过全局缓存共享状态。只有完整成功的求值才能发布新结果。匹配器失败时保留规则 ID 与 cause,不发布部分分段;实际读取仍参与依赖驱动的重试。禁止重入求值以及逃逸的同步读取 Context。 + +`conversation.user.text` 保持 session 作用域 Factory,拥有 keyed inline Slot `conversation.user.text.node`。React 消费最终节点,将 `raw` 和 `interactive` 交给业务 renderer。Session、skill、文件/目录和命令匹配仍归各插件所有,动作仍由各 renderer 的 Injection 提供,匹配与缓存订阅不进入 React。 + +`collapseWhitespace` 与 `trimWhitespace` 分别控制一个普通匹配器,缺省均关闭。Collapse 将空白串作为显示值为 `" "` 的 Text 候选;trim 将原文开头/末尾的空白串作为显示值为 `""` 的候选。Trim 的 order 小于 collapse,因此在同起点的边缘空白冲突中胜出。更早的引用候选消费自身完整范围,自然排除内部空白候选。每个 Text 候选保留准确的原文子串,emoji 及引用之间的空格保持不变。`interactive` 和 CSS `ellipsis` 仍为 React Props,不是匹配 Facts。Queue 使用完整原文和 CSS 省略,原生附件保留既有卡片及图片 Slot,不进入文本识别。 + +## 验证要求 + +纯逻辑测试同时断言最终节点,以及每个匹配器有序的调用和 `from` 偏移。使用确定性输入与 spy,不依赖时钟或性能阈值。 + +- 初始化调用所有匹配器,包括不读 Facts 和始终无匹配的规则;空输入具有明确的终止查询行为。 +- 较早起点胜过更强 order;同起点按 order 和稳定 ID 裁定,各匹配器保留自身最长匹配选择。 +- 向前遍历不重复查询未来候选及无匹配结论,只推进赢家和重叠候选头。 +- 未读取 Facts 字段变化,或字段值不变而外层 Facts 对象换引用,不调用匹配器。 +- 一个或多个字段变化只重跑实际读取者;覆盖缺失字段、落败候选、无匹配结果、失败和条件依赖集合替换。 +- 等价刷新保留节点/数组引用且不调用无关匹配器;仅 payload 变化保留无关节点。 +- 普通文字中插入、落败的新候选、同起点换赢家、匹配扩张/缩短、较早赢家移除,均断言准确的受影响查询偏移与次数。 +- 赢家移除或缩短后暴露缓存落败者及未搜索空隙;旧尾部的无匹配结论不能回答其查询起点之前的问题。 +- 批量 Facts 更新等于相同快照下的新建计算器结果,不赋予先刷新匹配器额外优先权。 +- 原文及规则定义变化、注册/注销、范围校验、同步失败隔离、Context 生命周期和计算器状态独立均有确定性测试。 +- 真实 Session/文件/skill/命令组合保留完整带引号引用、原始词边界和同起点优先关系;空白候选使用完全相同的调度和缓存规则。 + +## 备选方案 + +**按顺序处理整篇文档的业务变换。** 某业务可能在另一业务更早的引用获得考虑前消费后面的文本,因此固定类型顺序不能表达最左优先识别。 + +## 影响 + +字段级失效仍可能要求某个匹配器重新遍历文档,不承诺 Facts 字段内部逐元素失效。查询历史的内存量与当前原文、定义下记录的不同搜索位置成正比。协调及 JSON 引用对齐可能遍历输出而不调用匹配器。性能证据是准确的调度次数和查询偏移,语义证据是刷新结果与全新计算器的等价性。本机制不需要浏览器端到端测试或耗时基准测试。 diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml new file mode 100644 index 0000000000..3087777feb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-10-11-chat-group-tree-exchanges.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md +/agent-note-chat-group-tree-positions-and-exchanges: + en: b26212eda152d6c6 + zh: a801a75ee0c07911 +/agent-note-chat-group-tree-positions-and-exchanges/problem: + en: 84858bc0e1e268ab + zh: 39ebb2979968d6cc +/agent-note-chat-group-tree-positions-and-exchanges/decision: + en: c8c4091863dec269 + zh: 57a0f26683867350 +/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice: + en: db8d77cd773ad646 + zh: ab2f03b8fc58e952 +/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered: + en: 635ddcb04fefe8e2 + zh: 360c171ccb613ed5 +/agent-note-chat-group-tree-positions-and-exchanges/consequences: + en: c8cfdc3009b19b12 + zh: 36c541cd9692057d +/agent-note-chat-group-tree-positions-and-exchanges/testing: + en: 0d7f5281e66b0c39 + zh: 5be221de46e9e0f7 diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md new file mode 100644 index 0000000000..6c3a9f0df1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md @@ -0,0 +1,51 @@ +# Agent Note: Chat group-tree positions and exchanges + +Status: implemented + +English | [中文](2026-10-11-chat-group-tree-exchanges.zh.md) + +## Problem + +On next message folding kept only the last Turn of the loaded timeline expanded. A Turn woken by a notice (a settled subagent, goal round, schedule, or background job) is not submitted by this Client, yet its arrival made the preceding Turn historical and folded it. No own-input driver computed the exact remaining room for that fold, so the scroll layer reserved generic bottom space and a reader following the tail saw blank space. In the recorded Session the notice woke the next Turn 13–15 ms after the previous Turn ended; the minimal reproduction left 596 px blank that had not recovered after five seconds. + +## Decision + +A notice is neither body text nor a user message: folding and bottom advancement ignore it. The bottom changes from the last Turn to the latest exchange. + +- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'turn' | 'process'` and ordered outermost first; a Node at a position also stands outside every inner grouping. `isAtOrOutside(node, position)` is the single reader of that order. Only `exchange` is read today; `turn` and `process` are reserved for migrating the `TURN_PROCESS_INDEPENDENT_KINDS` and process-group `INDEPENDENT` kind lists later without changing what `exchange` means. +- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; notices and later steering carry no position. +- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange". + +As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path. + +## Why mark the opening rather than the notice + +The host claims the waking input right after `turn/start`, assembles the system prompt, and only then logs `step/start` and `user/message`; the recorded Session spent 187 ms in that gap. Marking only the notice leaves the new Turn untagged during the gap, so the bottom still advances and the previous Turn folds and then reopens. Marking the human opening and letting unmarked Turns continue the exchange keeps the gap inert. + +## Alternatives considered + +**Tag the notice as a continuation.** The 187 ms gap above makes it fold and reopen, and a claim that mixes a notice with human input needs extra classification. + +**Name opening kinds in a list (`user`, `steering`).** A kind cannot separate Step 1 steering from mid-Turn steering, so a human steering into a notice-woken Turn would wrongly fold the previous exchange. + +**Publish Turn-level Location data.** Only one Context may publish a key for a Turn, while opening input comes from several message Contexts; aggregating per Turn would repeat the inbox claim classification in a new Definition and read every Turn on each update. + +**Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position. + +**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab. + +**Migrate both kind lists now.** That reaches well beyond this defect; the type reserves the positions and the migration is separate work. + +## Consequences + +- Notice-woken Turns no longer cause a fold, reserved room, or blank space, and the grouping mechanism reads declared positions instead of naming kinds. +- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again. +- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange. +- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here. + +## Testing + +- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`: position order, segmentation, count fallback, and the empty window. +- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`: latest-exchange membership and notification scope. +- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer. +- `apps/web/tests/notice-wake-tail.e2e.ts`: after a long Turn a notice wakes the next Turn; the long Turn stays expanded through the pre-Step gap and after the notice reply, and a following reader sees the notice and the reply. diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md new file mode 100644 index 0000000000..170a82fb92 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md @@ -0,0 +1,51 @@ +# Agent Note: Chat 分组树位置与交互段 + +Status: implemented + +[English](2026-10-11-chat-group-tree-exchanges.md) | 中文 + +## Problem + +「下次发送时收起」原本只让已加载时间线的最后一个 Turn 保持展开。由通知唤醒的 Turn(子 agent 完成、Goal Round、定时任务、后台作业)不是本客户端提交的,它一出现,上一轮就被当作历史折叠。这次折叠没有自己输入的驱动来计算精确的剩余补白,滚动层只能按通用规则预留底部空间,正在跟尾的读者因此看到一片空白。录制的真实 Session 中,通知在上一轮结束后 13–15 ms 唤醒新轮次;最小复现里空白高 596 px,五秒后仍未恢复。 + +## Decision + +通知不算正文,也不算用户消息:折叠和推进底部时都忽略它。底部从"最后一个 Turn"改为"最新交互段"。 + +- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时也站在所有内层分组之外。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口。目前只读取 `exchange`;`turn` 和 `process` 预留给以后把 `TURN_PROCESS_INDEPENDENT_KINDS` 与过程组 `INDEPENDENT` 两份 kind 名单迁移过来,届时不改变 `exchange` 的含义。 +- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;通知和之后的 steering 不标。 +- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。 + +结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。 + +## 为什么标开头而不是标通知 + +Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示,再写 `step/start` 和 `user/message`。录制的 Session 中这段间隔为 187 ms。只给通知打标的话,这段时间新 Turn 还没有标签,底部照样推进,上一轮会先折叠再展开。把标签放在真人开头上、未标的 Turn 默认续接,空白间隔里什么都不会发生。 + +## Alternatives considered + +**给通知打"续接"标签。** 上面那 187 ms 的间隔会让它先折后展;同一次认领里通知和真人输入并存时还要额外判断。 + +**按 kind 写一张开启名单(`user`、`steering`)。** kind 区分不了 Step 1 的 steering 与 Turn 中途的 steering,真人往通知唤醒的 Turn 里插话时会错误地折叠前一个交互段。 + +**发布到 Turn 级 Location data。** 同一 Turn 的同一个 key 只允许一个 Context 发布,而开启输入来自多个消息 Context;改成按 Turn 汇总就得在新的 Definition 里重复 inbox 认领分类,并且每次都要读取所有 Turn。 + +**节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。 + +**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。 + +**这次就迁移两份 kind 名单。** 改动面明显超出这次缺陷;类型先预留位置,迁移另做。 + +## Consequences + +- 通知唤醒的 Turn 不再产生折叠、补白和空白;分组机制读取节点声明的位置,而不是点名 kind。 +- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。 +- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。 +- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。 + +## Testing + +- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`:位置顺序、切分、计数回退与空窗口。 +- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`:最新交互段的成员与通知范围。 +- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer。 +- `apps/web/tests/notice-wake-tail.e2e.ts`:长 Turn 之后由通知唤醒,在 Step 1 前的间隔和通知回复之后,上一轮都保持展开,跟尾读者看到通知与回复。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.i18n.yaml new file mode 100644 index 0000000000..1fe1553059 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-15-authenticated-browser-host-preferences.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.md +/agent-note-share-host-preferences-with-every-authenticated-browser: + en: f4b8b4500f8dca9c + zh: bf27ded54fba703b +/agent-note-share-host-preferences-with-every-authenticated-browser/problem: + en: 22bf57b616581253 + zh: d1108a4042646e21 +/agent-note-share-host-preferences-with-every-authenticated-browser/decision: + en: 2d54784b8b3558cf + zh: 31190a9b284867b7 +/agent-note-share-host-preferences-with-every-authenticated-browser/alternatives-considered: + en: 299105745b084fc0 + zh: 8c2d82de44ac2aad +/agent-note-share-host-preferences-with-every-authenticated-browser/consequences: + en: 520028b8c4031009 + zh: 8c130624261538cc +/agent-note-share-host-preferences-with-every-authenticated-browser/testing: + en: e433f139fe72e69f + zh: 279cc8b1cd3d5377 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.md b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.md new file mode 100644 index 0000000000..120903b88d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.md @@ -0,0 +1,29 @@ +# Agent Note: Share Host preferences with every authenticated browser + +Status: implemented + +English | [中文](2026-09-15-authenticated-browser-host-preferences.zh.md) + +## Problem + +[Persist Web user preferences through Host settings](../../archived/bug-fix/2026-08-06-host-backed-web-preferences.md) chose the preference store by page locality, so a non-loopback page kept Appearance, Language, busy-Enter, and the welcome acknowledgement in a process-local copy. A trusted remote host, and a deployment published behind the configured public URL, therefore lost every choice on reload even though Connection authenticates the complete settings API. The second mode also split the shared runtime: theme, locale, the settings shell, the Models page, and the welcome notice each rendered a durable row that could never persist for a remote page. + +## Decision + +Locality selects nothing. One `settings.describe` mirror, one `ctx.configForms.get(entryId)` per namespace derived from it, and the `settings.mutate` write path serve every authenticated browser. Browser token authentication of the Connection API is the identity boundary; the fixed `remote.$host` locality facts are request-routing evidence that no store selector consults. The mirror's state space is `idle`, `loading`, and `ready`, a form snapshot carries no mode, and a form reports `unavailable` only for a namespace the Host did not register. + +The settings shell keeps one locality-dependent UI element: it builds its document controller from `ctx.remote.$host.isLoopback`, so only a loopback page renders **Open configuration file** and issues the `hasDocument` read behind it. The `settings/openSettingsDocument` Remote itself stays browser-authenticated like every other Host method; the omission is a presentation choice for pages that cannot see the Host's native editor, not a persistence rule. + +## Alternatives considered + +**Keep the memory-branch locality gate.** Rejected: browser token authentication already covers every Host method for remote pages, so the gate protected nothing, and locality is request-routing evidence rather than identity. It also made every Host-backed row inert on trusted remote and public deployments. + +## Consequences + +Every authenticated browser reaching one Host shares one preference document. Two browsers on the same deployment observe each other's Appearance, Language, busy-Enter, and welcome-acknowledgement choices, and a value written remotely is the value a loopback browser loads next. Preference access is governed by API authentication and `settings.describe.writable`. + +A remote page omits the **Open configuration file** action, the one locality-dependent UI element that remains. Concurrent writers on one document resolve through the namespace revision each form sends as `expectedRevision`. + +## Testing + +Focused unit coverage pins the contract: the ui-settings mirror and configuration-form suites construct both objects with no persistence selector, the theme apply spec persists from a non-loopback bench and asserts the Host write, the ui-settings-general apply spec keeps the shared describe read and the Host write path off-loopback while asserting the native document action stays unregistered, and the welcome store retains its acknowledgement across a settings-plugin recreation. The keyless Web settings scenario `apps/web/tests/remote-settings.e2e.ts` acknowledges the welcome notice, configures a model provider, and selects the dark Appearance from a trusted remote page, reloads after each, and re-asserts the welcome, model, and Appearance state from the Host settings document. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.zh.md new file mode 100644 index 0000000000..10aa7af847 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 每个已认证浏览器共用 Host 偏好 + +Status: implemented + +[English](2026-09-15-authenticated-browser-host-preferences.md) | 中文 + +## 问题 + +[通过 Host settings 持久化 Web 用户偏好](../../archived/bug-fix/2026-08-06-host-backed-web-preferences.md)按页面本地性选择偏好存储,因此非回环页面把 Appearance、Language、繁忙态 Enter 与欢迎提示的确认留在一份进程内副本中。可信远程主机,以及配置了公共 URL 的部署,因而在重新加载时丢失全部选择,尽管 Connection 认证完整的 settings API。这套第二种模式也割裂了共享运行时:主题、语言、设置外壳、Models 页面与欢迎提示各自都在渲染一行对远程页面永远无法持久化的界面。 + +## 决策 + +本地性不选择任何东西。一个 `settings.describe` 镜像、由它派生的逐 namespace `ctx.configForms.get(entryId)`,以及 `settings.mutate` 写入路径服务每个已认证浏览器。Connection API 的浏览器令牌认证是身份边界;固定的 `remote.$host` 本地性事实只是请求路由的依据,没有任何存储选择器会读取它。镜像的状态空间只有 `idle`、`loading` 与 `ready`,表单快照不携带 mode,表单也只有在 Host 未注册该 namespace 时才报告 `unavailable`。 + +settings 外壳保留唯一一个依赖本地性的 UI 元素:它依据 `ctx.remote.$host.isLoopback` 构造文档控制器,因此只有回环页面会渲染**打开配置文件**并发出其背后的 `hasDocument` 读取。`settings/openSettingsDocument` Remote 本身与其他 Host 方法一样保持浏览器认证;省略该操作是面向看不到 Host 原生编辑器的页面的展示选择,而非持久化规则。 + +## 曾考虑的替代方案 + +**保留内存分支的本地性门禁。** 已否决:浏览器令牌认证已覆盖远程页面的每个 Host 方法,该门禁保护不了任何东西,而本地性只是请求路由的依据而非身份。它还会让受信远程与公共部署上每一行依赖 Host 的界面失效。 + +## 后果 + +每个访问同一 Host 的已认证浏览器共用一份偏好文档。同一部署上的两个浏览器会看到彼此选择的 Appearance、Language、繁忙态 Enter 与欢迎提示确认,远程写入的值就是回环浏览器下一次加载的值。偏好可访问性由 API 认证与 `settings.describe.writable` 决定。 + +远程页面省略**打开配置文件**操作,这是仍依赖本地性的唯一 UI 元素。同一文档上的并发写入由各表单作为 `expectedRevision` 发送的 namespace revision 裁决。 + +## 测试 + +聚焦的单元测试固定当前契约:ui-settings 的镜像与配置表单测试在构造这两个对象时不传持久化选择器,主题 apply 测试从非回环 bench 持久化并断言 Host 写入,ui-settings-general 的 apply 测试在非回环下保留共享 describe 读取与 Host 写入路径、同时断言原生文档操作保持未注册,欢迎 store 则在 settings 插件重建后保留其确认。无密钥 Web settings 场景 `apps/web/tests/remote-settings.e2e.ts` 从可信远程页面确认欢迎通知、配置一个模型提供商并选择深色 Appearance,每步之后重新加载,并从 Host settings 文档重新确认欢迎提示、模型与 Appearance 状态。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml new file mode 100644 index 0000000000..56ecd753a6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-29-reused-blank-runs-requested-preset.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md +/agent-note-a-reused-blank-session-runs-the-requested-preset: + en: 06173bc82e1d9f59 + zh: 637352fd92142310 +/agent-note-a-reused-blank-session-runs-the-requested-preset/problem: + en: 7514025504dc2ddd + zh: 96249d3472005023 +/agent-note-a-reused-blank-session-runs-the-requested-preset/decision: + en: e396c291f742a5f3 + zh: c67be0fd305215fe +/agent-note-a-reused-blank-session-runs-the-requested-preset/alternatives-considered: + en: ae99345e8df800e4 + zh: a4d0c257c4216337 +/agent-note-a-reused-blank-session-runs-the-requested-preset/consequences: + en: bc7dda06670b4d96 + zh: 24f381b8cb6ed3f8 +/agent-note-a-reused-blank-session-runs-the-requested-preset/testing: + en: 1aefca569f5dce81 + zh: 527c9aacf5a2f6b8 diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md new file mode 100644 index 0000000000..0ebcb569a2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md @@ -0,0 +1,37 @@ +# Agent Note: A reused blank Session runs the requested preset + +Status: implemented + +English | [中文](2026-09-29-reused-blank-runs-requested-preset.zh.md) + +## Problem + +Web New Session, Workspace navigation, and startup restoration reused a blank Session by naming it in `session.create`. The Host resumed that Session under the preset its log recorded, so a new conversation inherited the preset the blank had been created or switched under. When that preset was no longer declared, such as a removed or renamed definition or a legacy `$DSH_HOME/.agent-presets//` directory that nothing reads any more, every New Session in that Workspace and every startup restoration of that draft failed with `agent-preset/not-found` although the default preset was valid (issue #2566). A blank created under an earlier default silently kept that default. The client retried only `session/writer-held`, so the stale blank stayed first in line. + +## Decision + +`session.create` returns a Session for a new conversation. A named `sessionId` is a reuse candidate that the Host verifies: it must not have started a turn, must belong to the requested directory, must be an unarchived member of the requested Workspace, and must not be written by another owner. For a persisted candidate the Host repeats the started check under the writer it acquires. Requests naming one identity run in arrival order, including their preset resolution, and the directory and ownership checks precede that resolution. The reused Session runs the requested `agentPreset`, or the configured default when the request names none; the Host does not resolve the recorded preset for reuse. When the target differs, a live Session switches through the preset registry's `select`, and a persisted Session mounts the target in its unpublished resume setup and appends `agent-preset/selected` there, so the log names the composition before the Agent is published. A candidate that has started, fails placement, or is written by another owner stays unchanged, and the Host creates a new Session; the response names the Session used. An explicit request for an undeclared preset still fails with `agent-preset/not-found`, and resuming a started Session still resolves its recorded identity. + +The Web client names the first eligible blank when it connects a Workspace or prepares an explicit draft, and passes the preset a flow knows before it starts, such as the creator entry's `cordis`; a plain New Session creates a fresh Session instead. Startup restoration names the saved blank with the preset its log recorded, reloading the Session's projections first because listed values are cached hints that can lag the log after an unclean Host exit, so a draft keeps its selection; after `agent-preset/not-found` or `agent-preset/invalid` it asks again without a preset, and the draft continues under the default. When the Host declines a named blank, the client reloads that Session's projections, because stale cached list metadata can still mark a started conversation blank and hide it from the Session tree. + +## Alternatives considered + +**Fall back to the default only when the recorded preset is missing.** This removes the failure but keeps an inherited preset whenever it still resolves, so a New Session after a default change runs the old default. + +**Retry `agent-preset/not-found` in the client.** The stale blank stays the first reuse candidate, so later New Sessions fail once before creating another Session, and startup restoration and other callers still fail. + +**Skip blanks whose preset is missing from the roster.** Navigation would need the roster, stale Sessions would never be repaired, and other callers would still fail. + +**Let the Host choose the blank.** The Host would scan the catalog on every request, while the client already holds the ordered catalog; verification under the writer covers a stale client view. + +**Keep a restored draft's preset on the Host when it resolves.** A request that names a blank without a preset would then mean "keep" for restoration and "default" for New Session. Passing the recorded preset gives each request one meaning. + +## Consequences + +A reused blank runs the default or the preset its flow names, including after the default changes or a definition disappears. A preset picked on a draft survives a page reload but not a later Workspace connection or draft preparation that reuses the draft, and reusing a draft that another tab shows changes that tab's preset too. Writer contention no longer reaches the client: the Host creates the replacement Session. A blank whose first prompt was accepted but whose turn has not started still counts as blank, as it does for `select`. A declined stale blank is corrected only in the client that named it, until the Session is next activated. The preset registry's `select` leaves a blank Session that records the requested preset and runs its current revision unchanged, so a client that also selects that preset after the creation request adds no second record. + +This note replaces the client-side contention fallback of [Writer acquisition for blank Session reuse](../architecture/2026-09-17-process-local-blank-sessions.md); writer acquisition and candidate order remain as decided there. + +## Testing + +Session Controller tests cover live and persisted reuse, a recorded preset that is no longer declared, named presets, started and overtaken Sessions, writer contention, Workspace placement, storage failures, arrival order when an earlier preset resolves later, and directory checks before preset resolution. Workspace navigation tests cover restoration with and without a usable recorded preset, restoration when the listed preset lags the log, connection coalescing that a different preset request supersedes, and the projection reload for a declined blank; the preset plugin tests cover the creator entry, and the registry tests cover a repeated selection with and without a replaced definition. The shipped Web composition test reuses a blank recorded under an undeclared preset under the default when startup connects its Workspace and when startup restores it, keeps a restored draft's declared preset, and checks the durable `agent-preset/selected` record; the stale blank cache scenario checks that startup opens a new Session and still lists the declined conversation. diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md new file mode 100644 index 0000000000..28c717f958 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 被复用的空白会话运行请求的 preset + +Status: implemented + +[English](2026-09-29-reused-blank-runs-requested-preset.md) | 中文 + +## Problem + +Web 的新建会话、工作区导航与启动恢复过去通过在 `session.create` 中指明空白会话来复用它。Host 过去按日志记录的 preset 恢复该会话,因此新对话继承了空白会话创建时或切换后的 preset。当该 preset 已不再声明时,例如定义被删除或改名,或是如今已无人读取的旧版 `$DSH_HOME/.agent-presets//` 目录,即使默认 preset 有效,该工作区中的每次新建会话以及该草稿的每次启动恢复都会以 `agent-preset/not-found` 失败(issue #2566)。在更早默认值下创建的空白会话则会静默保留那个旧默认值。客户端只对 `session/writer-held` 重试,因此过期的空白会话始终排在复用候选首位。 + +## Decision + +`session.create` 返回一个用于新对话的会话。请求指明的 `sessionId` 是由 Host 核实的复用候选:它必须尚未开始任何轮次、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用。对于持久化候选,Host 会在取得写锁后再次检查它是否已开始。指明同一标识的请求按到达顺序执行,preset 解析也包含在内,且目录与归属检查先于该解析。被复用的会话运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset;Host 复用时不解析记录的 preset。目标与当前不同时,活动会话通过 preset 注册表的 `select` 切换;持久化会话在尚未发布的恢复 setup 中挂载目标 preset,并在同一处追加 `agent-preset/selected`,使日志在 Agent 发布前就写明其组成。已开始、不满足归属或被其他写者占用的候选保持不变,Host 改为新建会话;响应指明实际使用的会话。显式请求未声明的 preset 仍以 `agent-preset/not-found` 失败,恢复已开始的会话仍解析其记录的标识。 + +Web 客户端在连接工作区或显式准备草稿时指明第一个符合条件的空白会话,并传入流程在开始前已确定的 preset,例如创造模式入口的 `cordis`;普通新建会话则直接新建 Session。启动恢复指明已保存的空白会话及其日志记录的 preset,并先重新加载该会话的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于日志,从而使草稿保留其选择;遇到 `agent-preset/not-found` 或 `agent-preset/invalid` 后改为不带 preset 再请求一次,草稿在默认 preset 下继续。Host 拒绝复用某个指明的空白会话时,客户端会重新读取该会话的投影,因为过期的列表缓存元数据可能仍把已开始的对话标为空白,并把它从会话树中隐藏。 + +## Alternatives considered + +**只在记录的 preset 缺失时回退到默认值。** 这能消除失败,但只要继承的 preset 仍可解析就会保留它,因此默认值变更后的新建会话仍运行旧默认值。 + +**在客户端重试 `agent-preset/not-found`。** 过期的空白会话仍是第一个复用候选,之后每次新建会话都会先失败一次再新建,启动恢复和其他调用方仍会失败。 + +**跳过 preset 不在列表中的空白会话。** 导航需要读取 preset 列表,过期会话永远得不到修复,其他调用方仍会失败。 + +**由 Host 选择空白会话。** Host 每次请求都要扫描会话列表,而客户端已持有排好序的列表;在写锁下的核实足以覆盖客户端视图过期的情况。 + +**恢复草稿时由 Host 在记录的 preset 可解析时保留它。** 这样一个指明空白会话但未带 preset 的请求,在恢复时表示「保留」,在新建会话时表示「默认」。传入记录的 preset 让每个请求只有一种含义。 + +## Consequences + +被复用的空白会话运行默认 preset 或其流程指明的 preset,默认值变更或定义消失之后也是如此。在草稿上选择的 preset 在刷新页面后仍保留,但之后复用该草稿的工作区连接或草稿准备不会保留它;复用另一个标签页正在显示的草稿也会改变那个标签页的 preset。写锁争用不再到达客户端:由 Host 新建替代会话。首条提示词已被接受但轮次尚未开始的空白会话仍算空白,与 `select` 一致。被拒绝复用的过期空白会话只在指明它的客户端中得到纠正,直到该会话下次被激活。若空白会话已记录所请求的 preset 且正运行其当前版本,preset 注册表的 `select` 不做改动,因此客户端在创建请求之后再选择同一 preset 也不会追加第二条记录。 + +本注记取代 [复用空白 Session 时获取写锁](../architecture/2026-09-17-process-local-blank-sessions.zh.md) 中的客户端锁争用回退;写锁获取与候选顺序仍按该决策执行。 + +## Testing + +Session Controller 测试覆盖活动会话与持久化会话的复用、记录的 preset 已不再声明、指明的 preset、已开始与被抢先开始的会话、写锁争用、工作区归属、存储失败、较早请求的 preset 解析较慢时仍按到达顺序执行,以及目录检查先于 preset 解析。工作区导航测试覆盖记录的 preset 可用与不可用时的恢复、列表中的 preset 落后于日志时的恢复、被不同 preset 请求取代的连接不再合并,以及被拒绝的空白会话的投影重读;preset 插件测试覆盖创造模式入口,注册表测试覆盖定义未替换与已替换时的重复选择。实际 Web 组合测试在启动连接工作区与启动恢复时,把记录了未声明 preset 的空白会话复用到默认 preset 下,验证恢复的草稿保留已声明的 preset,并检查持久化的 `agent-preset/selected` 记录;过期空白缓存场景验证启动时打开新会话,并仍列出被拒绝复用的对话。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.i18n.yaml new file mode 100644 index 0000000000..edb4886429 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-10-background-shell-command-display.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.md +/agent-note-separate-background-shell-command-display-from-job-state: + en: c531a4b2eb444a12 + zh: 7c216f829d2e5168 +/agent-note-separate-background-shell-command-display-from-job-state/problem: + en: 116454bc385c0cc3 + zh: 241905c37c22cb45 +/agent-note-separate-background-shell-command-display-from-job-state/decision: + en: ca05afff8b542979 + zh: ccb19c8709718ca0 +/agent-note-separate-background-shell-command-display-from-job-state/alternatives-considered: + en: 573a8d2e99de14e6 + zh: 1e7767c951672b42 +/agent-note-separate-background-shell-command-display-from-job-state/consequences: + en: 15b6456f87cfeaa2 + zh: 5fec862a0180dc55 diff --git a/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.md b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.md new file mode 100644 index 0000000000..156aae0ae8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.md @@ -0,0 +1,33 @@ +# Agent Note: Separate background Shell command display from job state + +Status: implemented + +English | [中文](2026-10-10-background-shell-command-display.zh.md) + +## Problem + +An explicit background Shell call already carries its complete command and working directory at Start, but the Tool card shows generic argument JSON rather than the ordinary Shell command area. The original call returns a launch acknowledgement; the background result arrives through a separate completion notice or `job_output` call. Those messages do not directly carry the original Bash callId, so the acknowledgement cannot establish execution completion, and the original call's state cannot represent the job's live state. + +## Decision + +Valid explicit background `bash`/`pwsh` calls reuse the existing terminal command presentation. Before Start returns, the card indicates only that the launch call is in progress. After a normal launch acknowledgement, the same Tool card retains its command, working directory, wrapping, and disclosure state, and becomes a static command-only display without a run-state dot, exit code, empty-output placeholder, or continuing running animation. Launch failures retain the existing raw-argument and error-result presentation; Inspect still exposes the complete arguments and launch acknowledgement. + +The terminal component receives the original call's `background` flag from Start and combines it with the call's `running` value: `background=true` and `running=true` mean that the launch call is still in progress. When the acknowledgement arrives, `background` stays unchanged and `running=false` leaves a static command; it does not describe the background job's running state. This introduces no derived presentation parameter or job state, and does not keep `running=true` indefinitely to pretend that the command is still executing. Ordinary foreground calls, persistent Shell calls, and `terminal_send` retain their default behavior; the existing Copy button's output-copy semantics do not change. + +This decision covers explicit background launches only, not timeout promotion. It adds no job subscription, polling, output assembly, or callId/jobId index. Later completion notices and `job_output` keep their independent presentation, while the header's job panel owns live job information. The Tool card does not receive the final output, and Host interfaces, execution, and the Session log remain unchanged. + +The [Client tool-presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md#terminal-card) still governs raw-event derivation and business ownership. This note supersedes only its generic-body rule for explicit background Shell calls, not background `terminal_send`. + +## Alternatives considered + +**Parse the launch acknowledgement as an ordinary completed-command result.** The acknowledgement contains no command exit result and can therefore produce a false success indicator. + +**Retain Start's running state indefinitely.** The original Tool call has returned, and without a job subscription the card cannot establish that the background job is still running. + +**Match subsequent job output back to the original Tool.** This requires a reliable jobId-to-callId association and rules for incremental reads, missing history, and duplicate presentation. None is a prerequisite for displaying the existing command, so they remain outside this decision. + +**Keep generic argument JSON as the only presentation.** The data remains available, but the card does not reuse the familiar Shell command presentation and retains an unnecessary difference between foreground and background calls. + +## Consequences + +Users can inspect the launch command in its original Tool card but cannot infer whether the background job is currently running, successful, or failed. Final state and output remain in the job panel, completion notices, and independent read calls. Component and assembly regressions cover disclosure preservation from Start to acknowledgement, absence of false state and empty output, launch failures, root/child calls, and unchanged foreground and `terminal_send` behavior; assembly-regression event cases use explicitly identified constructed data. diff --git a/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.zh.md b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.zh.md new file mode 100644 index 0000000000..19090614e3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 主动后台 Shell 的命令展示与任务状态分离 + +Status: implemented + +[English](2026-10-10-background-shell-command-display.md) | 中文 + +## Problem + +主动后台 Shell 在 Start 时已经具有完整 command 和工作目录,但 Tool 卡片使用通用参数 JSON,而不是普通 Shell 的命令区域。原调用返回的是启动回执,后台最终结果通过另一条完成通知或 `job_output` 调用出现;这些消息没有直接携带原 Bash callId,不能把启动回执当成执行完成,也不能用原调用状态代表任务实时状态。 + +## Decision + +合法的主动后台 `bash`/`pwsh` 复用现有终端命令展示。Start 尚未返回时只表达启动调用正在进行;正常启动回执到达后,同一 Tool 卡片保留 command、工作目录、软换行及展开状态,切换为静态的仅命令展示,不显示执行状态点、退出码、空输出提示或持续运行动画。启动错误继续使用现有原始参数和错误结果展示;完整参数与启动回执仍可通过 Inspect 查看。 + +终端组件从 Start 起接收原调用的 `background` 标志,并结合调用的 `running` 决定展示:`background=true`、`running=true` 表示启动调用尚在进行;回执到达后 `background` 不变、`running=false`,只保留静态命令,不代表后台任务的运行状态。不新增派生展示参数或任务状态,也不以永久 `running=true` 伪造命令仍在执行。普通前台、持久 Shell 和 `terminal_send` 的默认行为不变;不改变原有 Copy 按钮的输出复制语义。 + +本次只处理主动后台启动,不修改超时转后台。没有 job 订阅、轮询、结果拼接或 callId/jobId 索引;后续完成通知和 `job_output` 保持原来的独立展示,标题栏任务面板继续拥有实时任务信息。Tool 卡片不回填最终输出,不修改 Host 接口、执行器或 Session 日志。 + +[Client 工具展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md#terminal-card)的原始事件派生与业务归属保持有效;本文仅替代其主动后台 Shell 使用通用正文的条目,不改变后台 `terminal_send`。 + +## Alternatives considered + +**直接按普通已结束命令解析启动回执。** 回执没有命令退出结果,容易显示虚假的成功状态。 + +**永久保留 Start 的 running 状态。** 原 Tool 调用已返回,且不订阅任务状态,无法保证后台任务仍在运行。 + +**将后续 job 输出匹配回原 Tool。** 需要可靠的 jobId 与原 callId 关联,还要定义分次读取、缺失历史和重复展示的处理;这些不是展示已有 command 的前提,本次不做。 + +**继续只展示通用参数 JSON。** 数据虽然存在,但没有复用用户熟悉的 Shell 命令展示,保留了后台与前台的不必要差异。 + +## Consequences + +用户能在原 Tool 卡片查看启动命令,但不能据此判断后台任务当前成功、失败或运行中。最终状态和输出仍在任务面板、完成通知及独立读取调用中查看。组件与装配回归覆盖 Start→回执的展开保持、无虚假状态与空输出、启动失败、root/child、普通前台及 `terminal_send` 不变;装配回归的事件用例使用明确标识的构造数据。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.i18n.yaml new file mode 100644 index 0000000000..f9ff8a3881 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-11-process-specific-prompt-values-in-runtime-context.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context: + en: 711ce3e3afb1f905 + zh: 2d4e4aea44043c4a +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/problem: + en: f4e050c7dbf645da + zh: 722bcdc409d09bec +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision: + en: 5222f00fd05d3801 + zh: fcfa430a0fa5588c +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/ownership: + en: c23d5c27f1f160f0 + zh: 5c2464666f1e6c29 +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/new-sessions: + en: 1ee144c39905bb89 + zh: a6669d7ad394e41c +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/earlier-format-sessions: + en: 27c11e7ced286898 + zh: 944d051494b1498d +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/alternatives-considered: + en: c324412be4034c40 + zh: 923ad612fc884be1 +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/consequences: + en: 8aa41ff08b848916 + zh: bc1c1647ca0eb86a +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/testing: + en: 9667d1749bdf1547 + zh: 598dc39120c91228 diff --git a/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md new file mode 100644 index 0000000000..86bda9b2a6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md @@ -0,0 +1,95 @@ +# Agent Note: Process-specific checkout path and Web URL move to runtime context + +Status: implemented + +English | [中文](2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md) + +## Problem + +`dsh web` wrote two process-specific values directly into the system prompt: the implementation checkout path in the `harness:source` section (four directories above the web-app module) and the Web GUI URL in the `app:web-surface` section (the bound port or `publicUrl`). These operations change them without the user perceiving a system-prompt change: + +| Operation | Changed value | +|---|---| +| Every Desktop launch (`--port 0` picks a random port) | Web URL | +| A different `--port`, or alternating A/B checkouts | Web URL, checkout path | +| npm and Desktop installations sharing one `DSH_HOME` | Checkout path | + +On resume the loop renders the prompt again. On a route without `systemPromptUpdate: 'in-history'`, such as a hand-declared pi-ai `anthropic-messages` route, the changed prompt rewrites the surface head node. Claude binds historical thinking-block signatures to the conversation prefix, system prompt included, so the upstream answers 400 (`Invalid signature … bound to a different conversation`) and the Session cannot continue until the environment is restored. In-history routes do not fail, but every change appends a complete prompt. + +## Decision + +Both values become runtime context and the system prompt keeps only value-free guidance; a Session whose log already holds the earlier format keeps its logged paragraphs. + +### Ownership + +| Package | Responsibility | +|---|---| +| `@deepseek-ai/dsh-harness-dev-context` (`packages/context/harness-dev-context`) | Both sections, both contexts, and the earlier-format projection; all module-private, exporting only the plugin protocol (`name`, `inject`, `apply`) | +| `@deepseek-ai/dsh-host-webserver` | `WebServer.getOrigin()` (the bind address's URL form with protocol and bound port) and `bindUrlHost(host)` | +| `@deepseek-ai/dsh-web-app` | The `web-runtime` row provides `webApp`, whose `getAdvertisedUrl()` returns the configured `publicUrl`, otherwise the listener origin, the same value as `DSH_WEB_URL` and the startup URL; the bundle patch mounts the `harness-dev-context` row | +| `@deepseek-ai/dsh-system-prompt` | `CONTEXT_ORDERS.HARNESS_SOURCE = 200` and `WEB_SURFACE = 210` | + +The plugin injects `systemPrompt`, `sessionProjections`, and `webApp` and reads those services in code; its patch row carries no configuration expression. The checkout path is four directories above this package's source or built entry, the same depth as the former web-app location, so source, npm, and Desktop installations resolve the same root. The URL is `webApp.getAdvertisedUrl()`, so a profile that overrides the `web-runtime` row's `publicUrl` reaches the model as well. The plugin reaches the `webApp` type through `import type {} from '@deepseek-ai/dsh-web-app'`; the import is erased at runtime, so the web-app bundle's dependency on this package remains the only runtime edge. + +### New Sessions + +| Registration | Content | +|---|---| +| Section `harness:source` (the `HARNESS_SOURCE` slot) | `The runtime context names the DeepSeek Harness implementation checkout. …`, the remaining guidance unchanged | +| Context `harness:source` (`CONTEXT_ORDERS.HARNESS_SOURCE = 200`) | `Current DeepSeek Harness implementation checkout: "".` | +| Section `app:web-surface` (the `WEB_SURFACE` slot) | `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. …`, the remaining guidance unchanged | +| Context `app:web-surface` (`CONTEXT_ORDERS.WEB_SURFACE = 210`) | `Current DeepSeek Harness Web GUI URL: "".` | + +Both contexts are optional (no `required`). The only complete persona, web-app's minimal preset, also disables optional runtime context, so it still sees neither value. Runtime context follows the existing mechanism: an unchanged value emits no snapshot, a change appends a complete snapshot before the step's user message, and resume compares against the latest logged snapshot, never rewriting a committed message. + +### Earlier-format Sessions + +The plugin registers the host-only session projection `harnessDevLegacyPrompt`, folding every nonempty `system/message` in event order into `{ source, web }`: the paragraphs of that prompt that match an earlier template verbatim, or `null`. + +| Earlier template | Matching rule | +|---|---| +| `harness:source` | Starts with `The DeepSeek Harness implementation checkout is at `, ends with the earlier fixed guidance, no line break between | +| `app:web-surface` | Starts with `You are interacting with the user through the DeepSeek Harness Web GUI at `, ends with `. ` plus the guidance as earlier releases rendered it, frozen as a literal, no line break between | + +Both section text providers read the projection state of `AssembleContext.agent.session`: with an earlier paragraph they return it verbatim (`interpolate: false`), otherwise the current guidance. Diagnostic assemblies without an agent use the current guidance. `sessionProjections` is a required injection and the plugin registers its own unit, so the state is readable whenever an agent exists. + +An earlier-format Session therefore satisfies both requirements: + +| Scenario | System prompt | Runtime context | +|---|---|---| +| First resume after upgrading, same port | Byte-identical to the log; the loop emits no `system/message` | Appends a snapshot with both current values | +| Port, slot, or installation change after upgrading | Still the logged earlier paragraphs | Appends a snapshot with the new values | + +The projection computes only on each committed `system/message`, adding no synchronous read of historical events; its state persists with projection checkpoints. When compaction or a new request series rewrites the head, the rendering still contains the same earlier paragraphs, so the projection state does not change. + +## Alternatives considered + +**Move both values to runtime context without keeping earlier paragraphs.** The upgrade itself changes the prompt text, so every existing Claude Session rewrites its head and fails with 400 on its first turn after the upgrade. + +**Offer `supportsMidConvoSystemMessages` on hand-declared Anthropic routes.** The upstream accepts mid-conversation system messages in a live probe, but the pi-ai catalog already supplies that capability for named Anthropic routes and hand-declared routes withhold catalog fields by convention; in-history routes would still append a complete prompt on every value change. + +**Ask the upstream to drop mismatched thinking blocks (`prefix_mismatch_behavior: "drop_block"`).** It silently loses historical reasoning and covers one vendor. + +**Freeze the whole system prompt per Session.** Legitimate prompt changes such as plan mode, skills, and tool guidance would also be frozen and never reach the model. + +**Keep the implementation in web-app and app-boot.** Development-facing context and earlier-format compatibility would enlarge the public API of two core packages; app-boot's `addHarnessSourceSection` had web-app as its only caller. + +**Read `webStartup.publicUrl`.** It carries only the `--public-url` flag, so a profile override of the `web-runtime` row's `publicUrl` would reach `DSH_WEB_URL` but not the model; the effective value exists only inside web-app, which now provides it as `webApp`. + +**Read the head node text from Session history directly.** The synchronous arbitrary-history read APIs are deprecated and new calls are prohibited; a projection is the established way to obtain that state after resume. + +## Consequences + +- A new Session's system prompt no longer changes with the port, slot, or installation; both current values arrive as runtime-context snapshots, and a change appends a few lines. +- An earlier-format Session keeps the old values of its earlier paragraphs for its whole lifetime; the later `Current …` runtime context in the same request gives the current values. +- Only these two paragraphs are protected. When other prompt text changes between releases, routes without in-history updates still rewrite the head. +- A release without this fix rewrites the head of a new-format Session with the earlier template on resume; both A/B checkouts need this fix before they alternate. +- app-boot no longer exports `addHarnessSourceSection` or `HARNESS_SOURCE_SECTION`; web-app's `surfaceContext` controls only `DSH_WEB_URL`, and disabling the `harness-dev-context` row turns both contexts off. +- Runtime context in recorded Web e2e Sessions gains two lines; the test scaffold normalizes the URL and checkout path to placeholders. + +## Testing + +- `packages/context/harness-dev-context/tests/harness-dev-context.spec.ts`: on a production AgentLoop, an earlier-format Session resumed on another port emits no `system/message`, the request's system prompt is byte-identical to the log, and runtime context carries the current URL; a second port change keeps this; replace-only and in-history routes each run. When a series consolidation only empties a later version, the next turn keeps the earlier paragraphs. Similar text without the fixed ending or broken across lines receives the current guidance. A new Session keeps its system prompt across a port change and only appends a runtime-context snapshot. Sections carry no values, the context carries the advertised URL, and disposing the plugin fiber removes every registration. +- `packages/host/webserver/tests/webserver.spec.ts`: `bindUrlHost` across address forms; `getOrigin()` on HTTP and HTTPS listeners, and its errors before binding and for a zoned non-loopback address. +- `packages/bundle/web-app/tests/web-app.spec.ts`: `webApp.getAdvertisedUrl()` returns the listener origin, or the row's configured `publicUrl`. +- `packages/core/system-prompt/tests/system-prompt.spec.ts`: the added context positions are unique integers. diff --git a/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md new file mode 100644 index 0000000000..b37e09e6bb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md @@ -0,0 +1,95 @@ +# Agent Note: 进程相关的 checkout 路径与 Web URL 改走运行时上下文 + +Status: implemented + +[English](2026-10-11-process-specific-prompt-values-in-runtime-context.md) | 中文 + +## 问题 + +`dsh web` 曾把两项随进程变化的值直接写进 system prompt:`harness:source` 段里的实现 checkout 路径(web-app 模块位置上溯四层),以及 `app:web-surface` 段里的 Web GUI URL(实际监听端口或 `publicUrl`)。下列操作都会改变它们,而用户感知不到 system prompt 变了: + +| 操作 | 变化的值 | +|---|---| +| Desktop 每次启动(`--port 0` 取随机端口) | Web URL | +| 换 `--port`、A/B 两个 checkout 交替运行 | Web URL、checkout 路径 | +| npm 安装与 Desktop 安装共用同一个 `DSH_HOME` | checkout 路径 | + +会话恢复时 loop 重新渲染 prompt。对不声明 `systemPromptUpdate: 'in-history'` 的路由(例如手写的 pi-ai `anthropic-messages` 路由),变化的 prompt 会改写 surface 头节点。Claude 的历史 thinking 块签名绑定包括 system prompt 在内的对话前缀,前缀一变上游返回 400(`Invalid signature … bound to a different conversation`),会话在环境恢复原样之前无法继续。声明 in-history 的路由不报错,但每次变化都追加一份完整 prompt。 + +## 决定 + +两项值改为运行时上下文,system prompt 只保留不含值的说明;已经按旧格式写入日志的会话继续沿用日志里的旧段落。 + +### 归属 + +| 包 | 职责 | +|---|---| +| `@deepseek-ai/dsh-harness-dev-context`(`packages/context/harness-dev-context`) | 两个 section、两个 context、旧格式投影;全部模块私有,只导出插件协议(`name`、`inject`、`apply`) | +| `@deepseek-ai/dsh-host-webserver` | `WebServer.getOrigin()`(绑定地址的 URL 形式加协议与实际端口)与 `bindUrlHost(host)` | +| `@deepseek-ai/dsh-web-app` | `web-runtime` 行提供 `webApp`,其 `getAdvertisedUrl()` 返回配置的 `publicUrl`,否则返回监听器 origin,与 `DSH_WEB_URL` 和启动 URL 同值;bundle patch 挂载 `harness-dev-context` 行 | +| `@deepseek-ai/dsh-system-prompt` | `CONTEXT_ORDERS.HARNESS_SOURCE = 200`、`WEB_SURFACE = 210` | + +插件 `inject` 为 `systemPrompt`、`sessionProjections`、`webApp`,在代码里直接读取这些服务;patch 行不写任何配置表达式。checkout 路径取本包源码或构建入口上溯四层,与原 web-app 位置同深度,源码、npm 与 Desktop 安装下结果不变。URL 取 `webApp.getAdvertisedUrl()`,因此 profile 覆盖 `web-runtime` 行的 `publicUrl` 时模型也能看到。插件通过 `import type {} from '@deepseek-ai/dsh-web-app'` 取得 `webApp` 类型;该导入在运行时被擦除,因此 web-app bundle 对本包的依赖仍是唯一的运行时依赖。 + +### 新会话 + +| 注册 | 内容 | +|---|---| +| section `harness:source`(`HARNESS_SOURCE` 位) | `The runtime context names the DeepSeek Harness implementation checkout. …`,其余说明不变 | +| context `harness:source`(`CONTEXT_ORDERS.HARNESS_SOURCE = 200`) | `Current DeepSeek Harness implementation checkout: "".` | +| section `app:web-surface`(`WEB_SURFACE` 位) | `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. …`,其余说明不变 | +| context `app:web-surface`(`CONTEXT_ORDERS.WEB_SURFACE = 210`) | `Current DeepSeek Harness Web GUI URL: "".` | + +两个 context 都是可选项(不设 `required`)。唯一的 complete persona(web-app 的 minimal 预设)同时关闭了可选运行时上下文,因此它仍然看不到这两项。运行时上下文按既有机制工作:值不变不发新快照,变化时在本轮 user 消息前追加完整快照,恢复时从会话日志取上一份快照比较,不改写任何已提交消息。 + +### 旧格式会话 + +插件注册仅限宿主的会话投影 `harnessDevLegacyPrompt`,按事件顺序折叠每个非空 `system/message`,状态为 `{ source, web }`:该 prompt 中按旧模板逐字匹配到的段落,未匹配为 `null`。 + +| 旧模板 | 匹配规则 | +|---|---| +| `harness:source` | `The DeepSeek Harness implementation checkout is at ` 开头、旧版固定说明结尾、中间不含换行 | +| `app:web-surface` | `You are interacting with the user through the DeepSeek Harness Web GUI at ` 开头、`. ` 加早期版本渲染的说明结尾(冻结为字面量)、中间不含换行 | + +两个 section 的文本提供函数读取 `AssembleContext.agent.session` 的投影状态:有旧段落时原样返回该段落(`interpolate: false`),否则返回新说明。没有 agent 的诊断性组装使用新说明。`sessionProjections` 是插件的必需注入,投影单元由插件自己注册,因此存在 agent 时状态必然可读。 + +旧会话因此满足两点: + +| 场景 | system prompt | 运行时上下文 | +|---|---|---| +| 升级后首次恢复,端口不变 | 与日志逐字相同,loop 不产生 `system/message` | 追加含两项当前值的快照 | +| 升级后换端口、slot 或安装位置 | 仍是日志里的旧段落 | 追加新值快照 | + +投影只在每次提交的 `system/message` 上计算,不新增同步读取历史事件的调用;状态随投影检查点持久化。压缩或新请求序列重写头节点时,渲染结果仍含同一旧段落,投影状态不变。 + +## 备选方案 + +**只把两项值移到运行时上下文,不保留旧段落。** 升级本身会改变 prompt 文本,所有既有 Claude 会话在升级后第一轮改写头节点并报 400。 + +**为手写 Anthropic 路由开放 `supportsMidConvoSystemMessages`。** 上游实测接受对话中途的 system 消息,但 pi-ai 目录已为具名 Anthropic 路由提供该能力,手写路由按既有约定不开放目录字段;而且 in-history 路由每次值变化仍追加一份完整 prompt。 + +**请求上游丢弃不匹配的 thinking 块(`prefix_mismatch_behavior: "drop_block"`)。** 会静默丢失历史推理,且只覆盖一个供应商。 + +**会话内冻结整份 system prompt。** plan 模式、技能和工具说明等合法的 prompt 变化也会被冻结,无法到达模型。 + +**留在 web-app 与 app-boot 中实现。** 面向开发的上下文与旧格式兼容逻辑会扩大两个核心包的公开 API;app-boot 的 `addHarnessSourceSection` 当时只有 web-app 一个调用方。 + +**读取 `webStartup.publicUrl`。** 它只携带 `--public-url` 参数,profile 覆盖 `web-runtime` 行的 `publicUrl` 时只会影响 `DSH_WEB_URL`,到不了模型;生效值只存在于 web-app 内部,现在由它以 `webApp` 提供。 + +**直接读取会话历史中的头节点文本。** 同步读取任意历史事件的 API 已弃用,新调用被禁止;投影是恢复后取得该状态的既定方式。 + +## 后果 + +- 新会话的 system prompt 不再随端口、slot 或安装位置变化;两项当前值以运行时上下文快照送达,变化时只追加几行。 +- 旧会话在整个生命周期内保留旧段落中的旧值;同一请求里更晚的 `Current …` 运行时上下文给出当前值。 +- 只保护这两段。其他提示文本在版本之间变化时,不支持 in-history 的路由仍会改写头节点。 +- 不含本修复的旧版本恢复新格式会话时会按旧模板改写头节点;A/B 两个 checkout 都需包含本修复后再交替运行。 +- app-boot 不再导出 `addHarnessSourceSection` 与 `HARNESS_SOURCE_SECTION`;web-app 的 `surfaceContext` 只控制 `DSH_WEB_URL`,关闭这两项上下文改为禁用 `harness-dev-context` 行。 +- Web e2e 录制会话里的运行时上下文多出两行;测试脚手架把 URL 与 checkout 路径规范化为占位符。 + +## 测试 + +- `packages/context/harness-dev-context/tests/harness-dev-context.spec.ts`:真实 AgentLoop 上,旧格式会话在另一端口恢复时不产生 `system/message`、请求中的 system prompt 与日志逐字相同、运行时上下文携带当前 URL;再换一次端口仍成立;不支持与支持 in-history 的路由各跑一遍。series 合并只清空后续版本时,下一轮仍沿用旧段落。缺少固定结尾或跨行的相似文本得到新说明。新会话换端口后 system prompt 不变、只追加运行时上下文快照。section 不含值、context 携带公告 URL,销毁插件 fiber 后全部注册消失。 +- `packages/host/webserver/tests/webserver.spec.ts`:`bindUrlHost` 各地址形式;`getOrigin()` 在 HTTP 与 HTTPS 监听上的值,绑定前与带 zone 的非回环地址抛错。 +- `packages/bundle/web-app/tests/web-app.spec.ts`:`webApp.getAdvertisedUrl()` 返回监听器 origin,或该行配置的 `publicUrl`。 +- `packages/core/system-prompt/tests/system-prompt.spec.ts`:新增的 context 位置唯一且为整数。 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index 38cc4a8bf5..e3bd9b93f5 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml @@ -39,8 +39,8 @@ en: 761eb6c1aeb7af18 zh: 5434456a3d0a1b65 /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/testing: - en: f5d287f610962a48 - zh: 88368e1df517b087 + en: 6e637b0970682265 + zh: 7fed6e98f87b5f1a /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/alternatives-considered: en: e78530994a88d14f zh: 49f64c7f1421f507 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index c49b21ecee..97fc5b04db 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -101,6 +101,8 @@ Deployments switching to `'ptc'` must update any native-only `toolOrder`. Assemb - **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a PTC mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. - **Snapshot:** The `ptc-turn`, `both-mode-turn`, and `ptc-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. The TypeScript SDK PTC scenario mounts its Node runtime through an explicit test-owned profile patch and pins Session events and JSON-RPC notifications; its expected response and completed-turn checks run before refresh writes. +The optional [Node in-memory execution mode](../architecture/2026-09-23-best-effort-persistent-ptc.md) permits disposable cross-call memory without restoring objects or replaying programs after restart; the default execution mode stays stateless. + ## Alternatives considered **An add-on consumer plugin with zero core changes.** Rejected because `agent/request` is call-config-only under [reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md), while transforming an assembled tool list would have to undo `toolOrder` canonicalization without owning its config and would depend on listener order. Which tools the model is offered, and in which representation, is the registry's single concern: native schemas and the SDK are two projections of one visible store. diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 669a63e991..381448d354 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -101,6 +101,8 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 - **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 PTC mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 - **快照:** `ptc-turn`、`both-mode-turn` 和 `ptc-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。TypeScript SDK PTC 场景通过测试拥有的显式 profile patch 挂载 Node 运行时,并固定 Session 事件与 JSON-RPC 通知;预期回复和完成轮次检查在 refresh 写入前执行。 +可选的[Node 内存保留执行模式](../architecture/2026-09-23-best-effort-persistent-ptc.zh.md)允许跨调用保留可丢失的内存,不在重启后恢复对象或回放程序;默认执行模式保持无状态。 + ## 曾考虑的替代方案 **一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../architecture/2026-07-05-reconstructable-requests.zh.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。 diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml index f985dfc4a3..787ac9d839 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml @@ -21,8 +21,8 @@ en: 237018634b5f9dcf zh: cbc49d9c1b794065 /agent-note-experimental-auto-review-before-each-tool-call/decision/process-catalog-and-children: - en: 6ebe6f90241fd322 - zh: eafdebc5277e83a1 + en: 0042c645360c8361 + zh: 5dba39466d376559 /agent-note-experimental-auto-review-before-each-tool-call/alternatives-considered: en: fe97128ed05b9e43 zh: 4aaa344933fa89b4 diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.md index c19e780d01..ff54f9d927 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.md +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.md @@ -58,7 +58,7 @@ A persisted Auto Session cannot publish when the complete integration is absent The permission owner publishes one complete process catalog through generated `permissionPresets` Remote methods; the BFF explicitly mounts it and forwards a payload-free invalidation event. One browser directory subscribes before reading and serves both pickers. Epoch and connection-generation checks publish only the winning complete result. A winning failure or connection reset clears the previous snapshot; only a later existing read, notification, or reset retries. Disposed or stale settlements cannot publish. Each catalog invalidation dismisses the slash picker and its pending confirmation through the command owner while preserving the draft, so a picker waiting on its own read keeps its failure and retry state; reopening loads the current catalog. Session projection carries current selection only, so catalog installation or removal writes no Session event or sequence. -Auto appears with a superscript `EXP` badge. Both visible current-session pickers require the experimental confirmation; explicit `/permission auto` is already consent. The composer uses the generic Menu's existing portal placement to stay within the viewport while keeping its 218–360px bounds. The slash popup keeps `min(220px, 100%)` with `max-width: 100%`, including when a narrow composer collapses its trigger. +Auto appears with an `EXP` badge in picker options; the selected composer control omits it. Both visible current-session pickers require the experimental confirmation; explicit `/permission auto` is already consent. The composer uses the generic Menu's existing portal placement to stay within the viewport while keeping its 218–360px bounds. The slash popup keeps `min(220px, 100%)` with `max-width: 100%`, including when a narrow composer collapses its trigger. The [delegation-time policy capture](../../../../packages/subagent/subagent/README.md) records Auto or Full access before the first await and appends that existing preset event after fork seeding and sandbox/approval overrides. One-shot and continuable creation share the rule; cold resume reads only the child log. Later parent switches do not alter that child, while a later child switch can win. Read Only and Workspace Write retain sandbox inheritance plus `approval: never` and may therefore remain `custom`. Auto children classify each call independently using the existing lineage and messages, without parent call metadata, delegation records, receipts, Header/descriptor additions, or a Session-format change. Out-of-process children retain their own permission systems after the parent delegation is allowed. diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md index bd99839132..a77aec0259 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md @@ -58,7 +58,7 @@ Reviewer 可以输出 reasoning blocks,随后恰好一个 JSON text block 和 Permission owner 通过生成的 `permissionPresets` Remote 方法发布一份完整进程目录;BFF 显式挂载它,并转发无 payload 的失效事件。一个浏览器目录在读取前订阅,为两个选择器提供数据。Epoch 与 connection-generation 检查只发布胜出的完整结果。胜出读取失败或 connection reset 会清空旧快照;只有后续既有读取、通知或 reset 才重试。已 dispose 或陈旧的结算不能发布。每次目录失效都通过命令 owner 关闭 slash 选择器与待确认对话框,同时保留草稿,因此等待自身读取的选择器仍保留失败与重试状态;重新打开时加载当前目录。Session 投影只携带当前选择,因此目录安装或移除不写 Session 事件或序号。 -Auto 带右上标 `EXP`。两个可见当前会话选择器都要求实验确认;显式 `/permission auto` 已构成同意。Composer 使用通用 Menu 既有 portal 定位保持在视口内,同时保留 218–360px 边界。Slash popup 保留 `min(220px, 100%)` 与 `max-width: 100%`,窄 composer 将 trigger 折叠时也一样。 +Auto 在选择器选项中带 `EXP` 标记;选中后的 composer 控件不显示该标记。两个可见当前会话选择器都要求实验确认;显式 `/permission auto` 已构成同意。Composer 使用通用 Menu 既有 portal 定位保持在视口内,同时保留 218–360px 边界。Slash popup 保留 `min(220px, 100%)` 与 `max-width: 100%`,窄 composer 将 trigger 折叠时也一样。 [委派时权限捕获](../../../../packages/subagent/subagent/README.zh.md)在首次 await 前记录 Auto 或 Full access,并在 fork seed 与 sandbox/approval override 之后追加该既有 preset event。单次与可继续创建共用此规则;cold resume 只读取 child 日志。后续 parent 切换不改变该 child,后续 child 自己的切换仍可胜出。Read Only 与 Workspace Write 保留 sandbox 继承加 `approval: never`,因此可能保持 `custom`。Auto child 使用既有 lineage 与消息独立分类每次调用,不增加父 call metadata、委派记录、receipt、Header/descriptor 字段或 Session format。进程外 child 在父委派获准后保留自己的权限系统。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 67d1d90e9b..83ef4ebea0 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -9,14 +9,14 @@ en: e6929494e60041a2 zh: 15bec8e8baabcab2 /agent-note-generated-third-party-notices/decision: - en: 11f3362b0f45debb - zh: 5953b0848b940b67 + en: e0a339aa86f13898 + zh: 9ab0c7de7bb185b5 /agent-note-generated-third-party-notices/testing: en: 194f87f780991068 zh: 58096d2339576d20 /agent-note-generated-third-party-notices/alternatives-considered: - en: 093ddc027e296bf0 - zh: 6b9e64d99dcefa11 + en: 7e9d8d7864503e46 + zh: 5b93a1130cfab260 /agent-note-generated-third-party-notices/consequences: - en: 860b806c5d393c98 - zh: 51b4ca11e2d3802a + en: ed2210ac6c133e6c + zh: 7957cb33da842642 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 0ebd540939..d233fd1eb4 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -14,9 +14,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [shared runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. -**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. - -One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. +**The test lane enforces freshness.** The committed bytes are asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs, including the required `node 24 / coverage` pull-request lane — the check adds no gate process, no scheduler slot, and no separate CI step. A contributor who changes a generator input — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — runs `pnpm run gen-third-party-notices`; `pnpm run verify-third-party-notices` remains available for a standalone check. No Git hook regenerates the file: browser inputs make every package source file a generator input, so a hook would run the 16-second generator on nearly every commit, while few commits change the output. The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the shared runtime lock's `pythonPackages`. Bundled entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The shared wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Bundled distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. @@ -38,7 +36,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa **Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases. -**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost. +**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, while the assertion inside a spec the test lane already runs reports the same staleness at no additional CI cost. **Enumerate the full transitive closure.** The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the `pnpm licenses list` renderer instead. @@ -52,7 +50,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa ## Consequences -A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command. +Commits pay no generator run. A dependency or browser-import edit that leaves the notices stale fails the test lane with an error that names the regeneration command. The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 323ad8ea15..b229c16396 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -14,9 +14,7 @@ Status: implemented [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [共享运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 -**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 - -有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除** manifest 不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 +**新鲜度由测试 lane 保证。** 已提交的字节由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件,其中包括 PR 必过的 `node 24 / coverage` lane——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。改动生成器任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——的贡献者运行 `pnpm run gen-third-party-notices`;需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。没有 Git 钩子重新生成该文件:浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每个提交都运行约 16 秒的生成器,而改变输出的提交很少。 文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及共享运行时锁文件 `pythonPackages` 中的每个分发包。内置条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。共享 wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。内置分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 @@ -38,7 +36,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通 **保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。 -**用专门的 `doc-sync`(文档同步门禁)校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位;更糟的是,它唯一的失败方式,就是在别人推完一个无关的依赖升级几分钟后,通知对方回去重跑一次生成器。改为提交时重新生成消除了这次打断,而把断言放进测试 lane 本就会跑的 spec 里,则以零额外 CI 成本保住了这项保证。 +**用专门的 `doc-sync`(文档同步门禁)校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位,而测试 lane 本就会跑的 spec 里的断言能以零额外 CI 成本报告同样的过期。 **列出完整传递闭包。** 闭包有数千个包,锁文件里已带精确版本,铺开只会淹没读者真正要评估的直接依赖。文件转而指向锁文件与 `pnpm licenses list`。 @@ -52,7 +50,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通 ## 后果 -此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及 manifest 的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。 +提交不运行生成器。改动依赖或浏览器侧 import 却未更新披露文件时,测试 lane 会失败,其报错会指明重新生成的命令。 生成器需要已安装的依赖树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。 diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml index 126601f6e3..246e224b7f 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml @@ -9,14 +9,14 @@ en: c8e926bfc65d7195 zh: e0e007960b68fe80 /agent-note-in-tree-persistence-type-history/decision: - en: 0d6257a57763ad48 - zh: 6ce3cb7228bba27a + en: 94be3d6c7df3063b + zh: 7e8fb08d7fd95f85 /agent-note-in-tree-persistence-type-history/decision/finalized-checkpoints: - en: 7dd42139850de3bf - zh: 0783bff2f0084f71 + en: b508035a163340a3 + zh: ba5b2b7e8cef4b2f /agent-note-in-tree-persistence-type-history/alternatives-considered: - en: 5cdac1b3f15f89f6 - zh: 36cda3b92d31c916 + en: 1cf6d0a5555ae50a + zh: 70ea1ba535e09442 /agent-note-in-tree-persistence-type-history/consequences: en: 672c8cb59d291dc2 zh: cbec90fbca92320a diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md index 87e87041dd..add0904f42 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md @@ -10,13 +10,15 @@ A persisted event can retain the same payload type expression while a referenced ## Decision +The [reader-compatibility review decision](2026-10-08-session-reader-compatibility-review.md) supersedes the automatic version floor. This note owns normalized type history, per-root acknowledgements, and accepted checkpoint preservation. + One normalized type model supplies the readable persistence catalog, the complete schema inventory, and transitive per-root digests. Roots identify the logical Session header, the physical JSONL header line, the event envelope, and every repository-declared event. Source locations, alias names, ordinary comments, and property order are presentation details outside the digest. Explicit compatibility annotations carry fingerprinted policy data under the [source-attribution policy](2026-09-17-persistence-schema-review.md). Historical records retain source file paths without line numbers because their selected source trees differ from the current checkout. Referenced and recursive types participate in structural comparison; opaque values retain explicit coverage limits. The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services. -Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [event-payload version rule](../../../../docs/persistence-changes/README.md#compatibility-rules) admits higher numeric payload versions when every old alternative remains unchanged and records the older-reader limitation. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. +Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [event-payload version rule](../../../../docs/persistence-changes/README.md#compatibility-rules) admits higher numeric payload versions when every old alternative remains readable and records the older-reader limitation. Other structural changes require an explicit same-version choice in the recording CLI or an increasing header version. Every structural difference needs an acknowledgement; its Compatibility and Verification sections explain both reader directions and executed checks. CI validates saved decisions without inferring how they were authored. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) owns adjacent migration work when a bump is required. -The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once. +The persistence-change document kind preserves compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once. Retrospective [release comparisons](../../../../docs/persistence-changes/releases/README.md) use a separate `persistence-release` kind and pinned tag manifest. They retain changed after schemas and reconstruct adjacent releases, including unchanged tags. Earlier releases predate these acknowledgement rules, so their classifications are informational and their observed versions are not rewritten to satisfy a modern version floor. Historical parsing preserves optional surface operations; the current parser and acknowledgement chain remain strict. This separation permits approximate backfills without turning them into authorization for current changes. @@ -28,7 +30,7 @@ Explicit update refreshes an unaccepted terminal record without deleting its aut ### Finalized checkpoints -Finalization records acceptance independently of publication. A terminal record must not reuse its original header increase to authorize another breaking change to already-written data. The [status record](../../../../docs/session-format-status.md#finalization-record) therefore requires a complete compatibility checkpoint: accepted record semantics remain locked, while compatible current-source changes proceed through new records and breaking changes require a higher writer. The existing classifier and unknown-event protocol are unchanged. This adds no runtime field and cannot detect behavior changes hidden behind unchanged declarations. Coordinated edits to the in-tree authority and checkpoint still require review. +Finalization records acceptance independently of publication. The [status record](../../../../docs/session-format-status.md#finalization-record) requires a complete compatibility checkpoint that locks accepted machine declarations and after schemas. Further schema changes need new acknowledgements; an explicit version-bump decision must contain its own header increase and cannot reuse an accepted transition. The checkpoint adds no runtime field, does not change unknown-event handling, and cannot detect behavior changes hidden behind unchanged declarations. Coordinated edits to the in-tree authority and checkpoint still require review. ## Alternatives considered @@ -40,7 +42,7 @@ Finalization records acceptance independently of publication. A terminal record **Treat every changed digest as a version bump.** Optional body additions, required-to-optional changes, and ordinary event additions do not always require a new persistence version. Conservative classification distinguishes these cases and leaves the compatibility explanation to review. -**Infer behavior from types or permit arbitrary compatibility overrides.** A type graph cannot prove replay semantics. The checker confines itself to detectable structure and rejects decisions below the mechanical classification; it does not claim to detect behavior-only changes. +**Infer behavior from types or require proof of human review.** A type graph cannot establish replay semantics, and a recorded choice cannot establish how it was authored. The checker validates declared transitions and header-version constraints. Reviewers inspect actual reader behavior and the acknowledgement’s explanation independently. ## Consequences diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md index cfe5c4f7bd..fade8ff41b 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md @@ -10,13 +10,15 @@ Status: implemented ## 决策 +[读取器兼容性评审决策](2026-10-08-session-reader-compatibility-review.zh.md)取代自动最低版本要求。本说明负责规范化类型历史、逐根确认与已接受检查点保留。 + 一个规范化类型模型提供可读持久化目录、完整 schema 清单和包含传递引用的逐根摘要。根标识逻辑会话头、物理 JSONL 头行、事件封装以及每个仓库内声明的事件。源码位置、别名名称、普通注释和属性顺序属于摘要之外的展示细节。历史记录保留源码文件路径而不包含行号,因为其选定的源码树与当前工作树不同。被引用类型和递归类型参与结构比较;不透明值保留明确的覆盖限制。显式兼容性标记依照[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)携带纳入指纹的策略数据。 专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。 -自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[事件载荷版本规则](../../../../docs/persistence-changes/README.zh.md#compatibility-rules) 允许在原样保留所有旧分支时增加更高的数字载荷版本,并记录旧读取器限制。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 +自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[事件载荷版本规则](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)允许在每个旧分支仍可读取时增加更高的数字载荷版本,并记录旧读取器限制。其他结构变更要求在记录 CLI 中显式选择同版本,或递增头版本。每个结构差异都需要确认;其兼容性与验证部分说明两个方向的读取器行为及已执行检查。CI 验证保存的决策,不推断其编写方式。需要升版本时,[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责相邻迁移工作。 -新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。 +持久化变更文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。 回溯性的[发行版本比较](../../../../docs/persistence-changes/releases/README.zh.md)使用独立的 `persistence-release` 类型和固定 tag 清单。它们保留变化后的 schema,并重建相邻版本,包括未变的 tag。早期版本先于这些确认规则,因此其分类只提供信息,观察到的版本号不会为满足当前最低版本要求而被改写。历史解析保留可选的 surface 操作;当前解析器和确认链仍保持严格。这一区分允许近似回填,同时不会将其变成当前变更的授权。 @@ -28,7 +30,7 @@ Status: implemented ### 定稿检查点 -定稿独立于发布事实记录接受状态。末端记录不能复用原有头版本递增,继续授权对已写入数据进行破坏性变更。因此,[状态记录](../../../../docs/session-format-status.zh.md#finalization-record)要求完整兼容性检查点:已接受记录的语义保持锁定,当前源码的兼容变更通过新记录继续,破坏性变更要求更高的写入器版本。现有分类器与未知事件协议保持不变。这不会添加运行时字段,也不能检测未变声明后隐藏的行为变化。对目录内真源与检查点的协同修改仍须审阅。 +定稿独立于发布事实记录接受状态。[状态记录](../../../../docs/session-format-status.zh.md#finalization-record)要求完整兼容性检查点,锁定已接受的机器声明与变更后 schema。后续 schema 变化需要新的确认;显式升版本决策必须包含自身的头版本递增,不能复用已接受转换。检查点不添加运行时字段,不改变未知事件处理,也不能检测未变声明后隐藏的行为变化。对目录内真源与检查点的协同修改仍须审阅。 ## 考虑过的替代方案 @@ -40,7 +42,7 @@ Status: implemented **把每次摘要变化都视为升版本。** 可选事件体新增、必选改可选,以及普通事件新增不一定需要新的持久化版本。保守分类区分这些情况,并将兼容性说明交给审阅。 -**从类型推断行为,或允许任意兼容性豁免。** 类型图无法证明回放语义。检查器仅覆盖可检测结构,并拒绝低于机械分类要求的决策;它不声称能够检测纯行为变更。 +**从类型推断行为,或要求人类评审证明。** 类型图无法证明回放语义,所记录的选择也无法证明其编写方式。检查器验证声明的转换与头版本约束。评审者独立检查实际读取器行为与确认说明。 ## 影响 diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml index 79f3339e71..192e40c2d2 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml @@ -9,8 +9,8 @@ en: e5c984fa32cc5e69 zh: 25d5742b2106d830 /agent-note-pr-artifact-builds-and-aggregate-typescript-checks/decision: - en: 6e84bceaba4573ca - zh: 25a37c147a675643 + en: af93d8849bb8831a + zh: 53e15e801ab844da /agent-note-pr-artifact-builds-and-aggregate-typescript-checks/alternatives-considered: en: f61da066f9ddcd62 zh: 816fd6e0063017cd diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md index 7577a7c06e..6c422a612d 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md @@ -12,13 +12,13 @@ PR jobs need the same compiled packages for benchmarks, compatibility, packaging Required Linux consumer and native Windows build jobs retain normal builds, including both complete aggregate programs. Public `build` and `typecheck` keep the same checks. [Node version ownership](2026-07-06-node-engine-floor.md) and [native Windows ownership](2026-08-08-native-windows-pull-request-ci.md) remain in force. -Checked PR artifact builds compile every existing project reference from each compiler face with one serial `tsc -b` process, then run the ordinary Typert, bundling, Desktop, and Web stages. The helper reads the existing Host and Client aggregates rather than maintaining another package list. It omits only each root's additional test and script program; package diagnostics, emitted outputs, and Host-before-Client ordering remain required. Non-PR release and real-API workflow builds retain their normal checks. +Checked PR artifact builds compile every existing project reference from each compiler face with one serial `tsc -b` process, then run the ordinary Typert, bundling, Desktop, and Web stages. The helper reads the existing Host and Client aggregates rather than maintaining another package list. It omits only each root's additional test and script program; package diagnostics, emitted outputs, and Host-before-Client ordering remain required. Non-PR release and real-API workflow builds retain their normal checks. That artifact build is `pnpm run build:artifacts`; [the build split](2026-10-11-build-artifacts-check-split.md) owns its steps and scheduling. Only the CI benchmark helper selects `tsc -b --noCheck` for package emission. Benchmarks consume JavaScript and Typert runtime metadata; their declaration and build-information files are disposable and never published. Typert analyzes source types independently, while required complete builds own semantic diagnostics. Syntax and configuration failures still reject benchmark preparation. TypeScript records pending checks so a later ordinary incremental build diagnoses the same source without deleting caches or forcing a rebuild. Public benchmark scripts and every release, Python, preview, compatibility, and normal artifact builder keep their checked defaults. -The Python executable builder selects artifact compilation with `--artifacts-only` after verifying its runtime closure. Invalid closure inputs fail before compilation, deployment, or packing; the default complete build and prebuilt `--skip-build` mode keep their existing stages. +The Python executable builder selects `build:artifacts` artifact compilation with `--build-artifacts` after verifying its runtime closure. Invalid closure inputs fail before compilation, deployment, or packing; the default complete build and prebuilt `--skip-build` mode keep their existing stages. -CI invokes the internal build helpers directly and keeps the root package manifest unchanged. Root manifest edits activate the separate native-addon platform matrix even when only build aliases change; avoiding that trigger preserves the existing workflow set and concurrent-job ceiling without weakening native validation. +PR artifact builds in CI invoke the root manifest's `build:artifacts`; a PR that edits these root manifest scripts activates the separate native-addon platform matrix, a trade-off [the build split](2026-10-11-build-artifacts-check-split.md) records. The vendor rehearsal and benchmark lanes invoke the internal build helpers directly. Job inventory, runner selectors, and benchmark sample counts stay fixed; gate and worker ceilings do not increase. The three compatibility cases run one job at a time; the two-target Python runtime matrix starts after the keyless Python SDK job succeeds. Four Linux lanes, the Python SDK, three Windows lanes, and one compatibility job permit at most nine worker jobs initially. Once the SDK job finishes, the remaining seven independent lanes, one compatibility job, and two runtime targets permit at most ten. The runtime plan and wheel stages precede those targets and each use one job. This permits runtime validation to overlap the Node 22 build within the original ten-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order. diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md index c0b3c1bcf4..9f25366401 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md @@ -12,13 +12,13 @@ PR job 需要相同的编译包来运行 benchmark、兼容性、打包和运行 必需的 Linux consumer 与原生 Windows build job 保留普通构建,包括两个完整 aggregate program。公开的 `build` 和 `typecheck` 保留相同检查。[Node 版本职责](2026-07-06-node-engine-floor.zh.md)与[原生 Windows 职责](2026-08-08-native-windows-pull-request-ci.zh.md)仍然有效。 -带检查的 PR 产物构建通过一个串行 `tsc -b` 进程编译各 compiler face 的全部现有 project reference,然后运行普通 Typert、bundling、Desktop 和 Web 阶段。helper 读取现有 Host 与 Client aggregate,而不维护另一份包清单。它只省略各 root 附加的测试和脚本 program;包诊断、emit 输出及 Host 先于 Client 的顺序仍然必需。非 PR 的 release 和真实 API 工作流构建保留普通检查。 +带检查的 PR 产物构建通过一个串行 `tsc -b` 进程编译各 compiler face 的全部现有 project reference,然后运行普通 Typert、bundling、Desktop 和 Web 阶段。helper 读取现有 Host 与 Client aggregate,而不维护另一份包清单。它只省略各 root 附加的测试和脚本 program;包诊断、emit 输出及 Host 先于 Client 的顺序仍然必需。非 PR 的 release 和真实 API 工作流构建保留普通检查。该产物构建即 `pnpm run build:artifacts`,其步骤与调度见[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)。 只有 CI benchmark helper 选择 `tsc -b --noCheck` 来 emit 包。benchmark 消费 JavaScript 和 Typert 运行时元数据;其声明与 build-information 文件是临时产物,不会发布。Typert 独立分析源码类型,语义诊断由必需的完整构建负责。语法与配置错误仍会拒绝 benchmark 准备。TypeScript 记录待检查状态,使后续普通增量构建无需删除缓存或强制重建,就能诊断相同源码。公开 benchmark script,以及所有 release、Python、preview、compatibility 和普通产物 builder,均保留带检查的默认行为。 -Python executable builder 在验证 runtime closure 后,通过 `--artifacts-only` 选择产物编译。无效 closure 在编译、部署或打包前失败;默认完整构建和复用预构建产物的 `--skip-build` 模式保留原有阶段。 +Python executable builder 在验证 runtime closure 后,通过 `--build-artifacts` 选择 `build:artifacts` 产物编译。无效 closure 在编译、部署或打包前失败;默认完整构建和复用预构建产物的 `--skip-build` 模式保留原有阶段。 -CI 直接调用内部 build helper,并保持 root package manifest 不变。即使只修改 build alias,root manifest 改动也会激活独立的 native-addon 平台矩阵;避免这一触发条件,可以在不削弱 native 验证的情况下保留现有 workflow 集合与并发 job 上限。 +CI 的 PR 产物构建调用 root manifest 中的 `build:artifacts`;修改 root manifest 中这些 script 的 PR 会激活独立的 native-addon 平台矩阵,[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)记录这一取舍。vendor 演练与 benchmark lane 直接调用内部 build helper。 job 清单、runner 选择和 benchmark sample 数量保持固定;gate 与 worker 上限不提高。三个 compatibility case 每次运行一个 job;双目标 Python runtime 矩阵在 keyless Python SDK job 成功后启动。四个 Linux lane、Python SDK、三个 Windows lane 和一个 compatibility job,初始最多占用九个 worker job。SDK job 完成后,剩余七个独立 lane、一个 compatibility job 和两个 runtime target,最多占用十个 job。runtime plan 与 wheel 阶段先于这些 target,各占一个 job。这允许 runtime 验证与 Node 22 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。 diff --git a/.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.i18n.yaml b/.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.i18n.yaml new file mode 100644 index 0000000000..45e17bc0e6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-08-session-reader-compatibility-review.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md +/agent-note-session-versions-follow-reader-compatibility: + en: b27e6ad9824a02e4 + zh: 034a5dc50838db3d +/agent-note-session-versions-follow-reader-compatibility/problem: + en: fa4f3e56f3b22e07 + zh: a8010ab822474c2c +/agent-note-session-versions-follow-reader-compatibility/decision: + en: ab45f42c587793e8 + zh: b1c2eab9e8642824 +/agent-note-session-versions-follow-reader-compatibility/migration-obligations: + en: b218b28f19c41f33 + zh: 7e99314983707984 +/agent-note-session-versions-follow-reader-compatibility/alternatives-considered: + en: be63c3e727b18cd3 + zh: 91b4e2ffaa42bdb9 +/agent-note-session-versions-follow-reader-compatibility/consequences: + en: 0ee67db581e8e03d + zh: ece4f775c8008f8a diff --git a/.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md b/.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md new file mode 100644 index 0000000000..194bc39632 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md @@ -0,0 +1,39 @@ +# Agent Note: Session versions follow reader compatibility + +Status: implemented + +English | [中文](2026-10-08-session-reader-compatibility-review.zh.md) + +## Problem + +A structural schema difference does not establish that a Session needs a new integer version. An optional environment header field illustrates the cost: sessions that omit the feature can retain identical logs, while sessions that require it can be refused by older readers. A global bump also excludes feature-free sessions and requires a new generation and adjacent migration. Conversely, an unknown field or value does not establish safe refusal: permissive readers can ignore required meaning, and recovery can mistake unsupported records for damaged tails. + +## Decision + +The user chose reader behavior as the version criterion. A same-version change is permitted when older readers either resume correctly, safely ignore optional features while preserving their records, or refuse required features before execution, mutation, or tail repair. Feature absence must preserve existing behavior. Required-feature refusal needs an upgrade diagnostic; already released binaries retain their existing diagnostics, so review identifies any limitation explicitly. + +New readers preserve the meaning of supported historical records and accept valid mixtures of old and new representations within one Session. Removing a required field or changing its representation is not automatically a bump: the replacement must remain distinguishable, and released-reader behavior must satisfy the same admission requirements. A version increase is required when no effective existing discriminator prevents unsafe interpretation. Reusing a logged value with a different meaning is one example; missing admission checks are another. + +The schema classifier identifies structural changes requiring compatibility review instead of declaring them inherently version-breaking. Every schema difference still needs an acknowledgement. Established safe cases infer same-version; other same-version changes require an explicit decision when recording or updating. The existing Compatibility and Verification sections record reader behavior and executed checks. The checker validates the decision and recorded schemas; it cannot prove that the explanation matches runtime behavior. An explicit version-bump decision still requires its own increasing `SessionHeader.version`. Accepted machine records and checkpoints remain immutable; prose remains outside checkpoint hashes. + +The user treats a recorded `same-version` decision as explicit regardless of whether the CLI or an editor produced it. The CLI requires `--decision same-version` for flagged record and update operations; CI validates the saved decision against history, schemas, and header-version constraints. Neither mechanism attests that a human reviewed the transition. Reviewers use base/head inventory comparison rather than current-tree consistency checks to inspect already acknowledged changes. + +This decision partially supersedes the structural bump criterion in the [version-mechanism note](../architecture/2026-08-10-session-log-version-mechanism.md) and the automatic version floor in the [persistence-type-history note](2026-09-11-persistence-type-history.md). Their monotonic integer, required unknown-event default, type history, and immutable generation decisions remain active. The [review procedure](../../../../docs/cookbook/reviewing-persistence-type-changes.md) owns authoring and validation details. + +## Migration obligations + +One codec per integer version and one migration per adjacent pair remain sufficient. A version's reader supports its released representations without changing established meanings; an eventual successor migration accepts that same historical range, including mixed records. Historical conversion can keep producing a valid older representation within the target version. Same-version reads do not run an adjacent migration, so a compatibility claim cannot depend on an unimplemented normalization step. Header and payload extensions still need feature-specific admission and preservation through their actual consumers. + +## Alternatives considered + +**Require bumps for header changes and other structural edits.** This mechanically excludes safe refusal and optional features, imposing migration work even when existing discriminators already protect readers. + +**Treat every unknown field or value as a refusal mechanism.** Released readers do not uniformly reject unknown values before recovery. Compatibility evidence must inspect the actual admission and mutation paths; a changed schema alone supplies no guarantee. + +**Permit same-version changes without recorded reader evidence.** A schema graph cannot establish safe execution, lossless retention, historical interpretation, or mixed-record behavior. Explicit review preserves these obligations without making the classifier pretend to prove them. + +**Add a separate JSON review object and hash its evidence.** The user rejected duplicating the existing Compatibility and Verification prose. Extra input fields and renewal rules add authoring work without establishing semantic safety. An explicit same-version choice and the existing acknowledgement keep the decision visible without a second evidence format. + +## Consequences + +The rule reduces unnecessary version transitions while making old-reader and new-reader evidence explicit. It changes review and acknowledgement enforcement, not runtime admission, diagnostics, codecs, or migration behavior. The environment example motivates the policy; it does not add an `env` field. Unsupported-feature detection, payload readers, and any necessary migration work remain obligations of each feature change. Focused verification must cover feature absence, optional retention or required refusal, historical input, and mixed representations as applicable; a written review is not a substitute for those results. diff --git a/.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.zh.md b/.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.zh.md new file mode 100644 index 0000000000..212db349f3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.zh.md @@ -0,0 +1,39 @@ +# Agent Note: Session 版本取决于读取器兼容性 + +Status: implemented + +[English](2026-10-08-session-reader-compatibility-review.md) | 中文 + +## 问题 + +schema 的结构差异不能证明 Session 需要新的整数版本。可选的环境头字段体现了这一成本:未使用该特性的会话可以保留完全相同的日志,需要该特性的会话则可以被旧读取器拒绝。全局升版本也会排除未使用特性的会话,并要求新的代际和相邻迁移。反过来,未知字段或值不能证明拒绝是安全的:宽松读取器可能忽略必需的含义,而恢复流程可能把不支持的记录误判为损坏的尾部。 + +## 决策 + +用户选择以读取器行为作为版本标准。当旧读取器能够正确恢复、安全忽略可选特性并保留其记录,或在执行、修改及尾部修复之前拒绝必需特性时,允许保持同一版本。未使用特性时必须保留现有行为。拒绝必需特性需要升级提示;已发布二进制保留其现有诊断,因此评审明确标出相关限制。 + +新读取器保留受支持历史记录的含义,并接受同一 Session 中新旧表示的有效混合。删除必需字段或改变其表示不自动要求升版本:替代表示必须可区分,已发布读取器的行为必须满足相同的准入要求。当不存在能够阻止不安全解释的有效既有判别标记时,必须升版本。将日志值复用于不同含义是一例;缺少准入检查是另一例。 + +schema 分类器标识需要兼容性评审的结构变更,而不将其直接判定为版本破坏。每项 schema 差异仍须确认。已明确安全的情形推断为同版本;其他同版本变更在记录或更新时要求显式决策。已有的兼容性与验证部分记录读取器行为和已执行检查。检查器验证决策与所记录的 schema,但无法证明说明与运行时行为一致。显式升版本决策仍须包含其自身递增的 `SessionHeader.version`。已接受的机器记录与检查点保持不可变;说明文字不参与检查点哈希。 + +用户将记录中的 `same-version` 决策视为显式选择,无论它由 CLI 还是编辑器产生。CLI 对被标记的记录与更新操作要求 `--decision same-version`;CI 按历史、schema 和头版本约束验证保存的决策。两种机制都不证明人类已评审转换。评审者使用 base/head 清单比较,而非当前目录一致性检查,来检查已确认的变更。 + +本决策部分取代[版本机制说明](../architecture/2026-08-10-session-log-version-mechanism.zh.md)中的结构性升版本标准,以及[持久化类型历史说明](2026-09-11-persistence-type-history.zh.md)中的自动最低版本要求。它们的单调整数、未知事件默认必需、类型历史与不可变代际决策仍然有效。[评审流程](../../../../docs/cookbook/reviewing-persistence-type-changes.zh.md)负责记录编写与验证细节。 + +## 迁移义务 + +每个整数版本一个 codec、每组相邻版本一个迁移仍然足够。一个版本的读取器支持其已发布表示,且不改变已确立的含义;后继迁移接受相同的历史范围,包括混合记录。历史转换可以继续产生目标版本内有效的旧表示。同版本读取不运行相邻迁移,因此兼容性声明不能依赖尚未实现的规范化步骤。头部与载荷扩展仍须在实际消费方中实现各自的准入与保留。 + +## 考虑过的替代方案 + +**对头部变更及其他结构编辑要求升版本。** 这会机械地排除安全拒绝和可选特性,即使既有判别标记已能保护读取器,也强加迁移工作。 + +**把每个未知字段或值都当作拒绝机制。** 已发布读取器并不统一在恢复前拒绝未知值。兼容性证据必须检查实际准入与修改路径;schema 变化本身不提供保证。 + +**允许同版本变更而不记录读取器证据。** schema 图无法证明安全执行、无损保留、历史解释或混合记录行为。明确评审保留这些义务,同时不让分类器假装已证明它们。 + +**添加独立的 JSON 评审对象并对证据做哈希。** 用户拒绝重复已有兼容性与验证说明。额外输入字段与更新规则增加编写工作,却不能证明语义安全性。显式同版本选择与已有确认记录使决策可见,无需第二种证据格式。 + +## 影响 + +该规则减少不必要的版本转换,同时明确要求旧读取器与新读取器的证据。它改变评审与确认校验,不改变运行时准入、诊断、codec 或迁移行为。环境示例说明政策动机,并未添加 `env` 字段。不支持特性的检测、载荷读取器及必要迁移仍由各项特性变更负责。针对性验证必须按需覆盖特性未使用、可选记录保留或必需特性拒绝、历史输入与混合表示;书面评审不能替代这些结果。 diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml new file mode 100644 index 0000000000..84099cc7a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for 2026-10-11-build-artifacts-check-split.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently: + en: b1a98fdd8008dce1 + zh: 15bdc2f2c2584226 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/problem: + en: bc382ad1f1e71352 + zh: 44189bf0f69a0bf7 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/commands: + en: 174241330463841d + zh: 7fe361c07e634053 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/steps-and-dependencies: + en: 43e78e255306cd43 + zh: a741d5ef1c7379f3 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/scheduling-failure-and-output: + en: 4b324f92307d3691 + zh: e0cf4411a747f7e5 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/callers: + en: 55c55eea07fdb459 + zh: ab78cdfa4d04a5e7 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/measurements: + en: b3a753ac2aab6073 + zh: 19cbd0e32751896f +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/completeness-and-lossless-evidence: + en: 3cd40e0db9a2c89a + zh: 3f708be239779d0a +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/intentional-trade-offs-not-lossless: + en: 14370bc5b54e1278 + zh: 7ac933092d3e9954 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/alternatives-considered: + en: 54905b912d17c74e + zh: 86a298004c8024e3 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/consequences: + en: 083467f68d0f03ed + zh: 93400e9994b8c645 diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md new file mode 100644 index 0000000000..4476424af5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md @@ -0,0 +1,157 @@ +# Agent Note: Split build:artifacts and build:check, with build running both concurrently + +Status: implemented + +English | [中文](2026-10-11-build-artifacts-check-split.zh.md) + +## Problem + +`pnpm run build` runs the native addon, the package-library build (Host `tsc -b tsconfig.host.json` → Host tsdown → Desktop bundle → Client `tsc -b tsconfig.client.json` → Client tsdown), and the Web build serially. Besides referencing package projects, the Host and Client aggregates each carry a `noEmit` root program covering tests, scripts, benchmarks, and website. These two root programs emit no files and feed no later artifact step, yet sit on the serial critical path: timed alone, the Host root program takes 68.5 s and the Client root program 36.0 s, while the serial package-library build takes about 249 s (median of 2). + +Development iterations need only artifacts; test and script type checks are needed before work is declared complete. The only way to skip the two root programs is the CI-oriented `pnpm run build --artifacts-only`, which has no local development command name and no counterpart that runs only those two checks. Meanwhile, `pnpm run build` must stay complete and detect every type error. + +## Decision + +### Commands + +Each command does only its own job. + +| Command | Responsibility | Steps | Scheduling | Client build record | +|---|---|---|---|---| +| `pnpm run build:artifacts` | Produces every artifact of `build`; package projects are fully type-checked as usual, the two aggregate root programs are not checked | native → host-src → host-bundle → desktop-bundle → client-src → client-bundle → web | One linear chain | Deleted at start, written after every step passes | +| `pnpm run build:check` | Type-checks only the two aggregate root programs | host-check, client-check | Both steps concurrent, no prerequisite steps | Neither read nor written | +| `pnpm run build` | Every step of `build:artifacts` and `build:check` | The dependency graph below | Concurrent through the run-gates scheduler | Deleted at start, written after every step passes | + +`build:check` neither detects, rebuilds, nor validates `build:artifacts` outputs; when prerequisite outputs are missing or stale, the compiler's own diagnostics (for example TS6305 and TS2307) report it, and no command adds prerequisite checks, guards, or fallbacks. `build:artifacts` produces the same artifacts and record as `build` and replaces `--artifacts-only`; that flag and the `libraries` mode of `compile-referenced-projects.ts` are removed, and the Python executable builder uses `--build-artifacts`. `build:official` remains `tsx scripts/build.ts --profile official` and runs the complete `build`. + +### Steps and dependencies + +| Step | Command | `needs` in `build` | +|---|---|---| +| native | `pnpm run build:native-system` | — | +| host-src | `node --max-old-space-size=4096 -b ` | native | +| host-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE host` (runs Typert) | host-src | +| desktop-bundle | `pnpm --filter @deepseek-ai/dsh-desktop run bundle` | host-bundle | +| client-src | ` -b ` | desktop-bundle | +| client-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE client` | client-src | +| web | `pnpm run build:web` | client-bundle | +| host-check | `node --max-old-space-size=4096 -p tsconfig.host.json` | host-src | +| client-check | ` -p tsconfig.client.json` | client-src | + +```text +native → host-src ─┬─ host-check ───────────────────────────────────────────────────────┐ + └─ host-bundle → desktop-bundle → client-src ─┬─ client-check ───────┤ → record + └─ client-bundle → web ┘ +``` + +- client-src must follow host-bundle: Client packages import `@deepseek-ai/dsh-*/remote`, whose types `lib/typert.remote-client.d.ts` Typert writes, inside Host tsdown in these scopes and through `scripts/generate-typert.ts` in the typecheck scope. host-check reads no Typert output; clean serial builds have always finished the Host aggregate check before Host tsdown. +- The artifact chain keeps the two non-data edges native → host-src and desktop-bundle → client-src; only the two check steps attach to the chain in parallel. +- [`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts) owns the package-library step list `libraryBuildSteps`, the references argument construction `referencedProjectsCompilerArgs` (relative paths keep the command below Windows' command-line limit), and the aggregate-check arguments `aggregateCheckArgs`. The vendor release rehearsal (`host-libraries`) and CI benchmarks (`--noCheck`) run the same step list serially. +- The check steps use project mode (`tsc -p`): it reads the declarations that references already emitted and compiles no reference, writing incremental state to the aggregates' existing `tsconfig.{host,client}.tsbuildinfo`. `build` and `build:check` run the same command, so their diagnostics are identical. + +### Scheduling, failure, and output + +- [`scripts/build.ts`](../../../../scripts/build.ts) owns the step table and the three scopes and executes them through `runAggregate` from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts): each step is a gate with `needs`, and shell-free spawning, per-step buffered output, fail-fast process-tree termination, Windows `taskkill`, and host signal forwarding come from the run-gates implementation. `build:artifacts` is `tsx scripts/build.ts --scope artifacts`, `build:check` is `--scope check`, and `build` omits `--scope` and runs both as one graph; `--profile` applies only to artifact scopes, and combining it with `--scope check` fails argument parsing. +- The concurrency limit is `DSH_GATE_CONCURRENCY`, defaulting to the available CPU count; the dependency graph is at most 3 wide (client-check and client-bundle can start while host-check is still running). Master serial reference lanes set `DSH_GATE_CONCURRENCY=1`, so `build` runs serially in topological order there, consistent with the [serial reference decision](2026-07-21-serial-cross-platform-ci-reference.md) and without workflow edits. +- By default (locally), every dependent of a failed step is recorded as skipped, while running steps and steps unrelated to the failure run to completion; the executed set depends only on step outcomes, so one run reports every independent type error. With `DSH_GATE_FAIL_FAST=1` (set in every building PR lane), the first failure terminates the process trees of the other running steps and records every unfinished step as skipped. +- A failed step prints `== FAILED ==`, its command, the exit reason, and the step's complete buffered output, and the final summary lists failed and skipped steps; a passing step prints only its PASS line, and `DSH_GATE_VERBOSE=1` prints all output. The exit code is 1 when any step failed or was skipped, and the client build record is not written. Every step inherits the public Client environment selected by `clientBuildProcessEnvironment`. +- In CI, `build` itself is one gate of an outer run-gates aggregate; outer fail-fast termination propagates to the inner scheduler. `check:node-compat` and `check:ci:lint:contracts-ready` already nest the same way. + +### Callers + +| Caller | Command | Aggregate root-program checks | +|---|---|---| +| `build`, `build:official`, `Makefile` `build`, `scripts/publish-npm-baseline.ts`, `apps/desktop/scripts/package-target.ts`, `scripts/dev-bundle-registry.ts`, `release-publish.yml`, `sandbox.yml`, and the non-PR branches of `e2e.yml` and `release.yml` | `build` / `build:official` | Yes | +| `test:web`, `test:web:refresh`, `test:web:perf`, `test:web:stress`, `apps/desktop` `test:updates:local`, `test:bench` / `build:bench` | `build`; `build:bench` runs `build:native-system`, `build:lib`, and the benchmark tsdown | Yes | +| `typecheck` (run by the pre-push hook) | `build.ts --scope typecheck`: host-src beside host-typert (the bundle-free, source-only Typert pass `generate-typert.ts`) → client-src, with `tsc -b tsconfig.{host,client}.json` after each face's package compilation; no bundle | Yes | +| `dev:web` ([`scripts/dev-web.ts`](../../../../scripts/dev-web.ts)), `dev:desktop` (`apps/desktop/scripts/dev.ts`) | `build:artifacts` | None before launch; after `dev:web` launches, its `tsc -b tsconfig.client.json --watch` keeps checking the Client aggregate root program | +| Prerequisite step of `lint`, `lint:fix`, `doc-typecheck`, and the CI Typert contracts gate | Host package compilation beside the bundle-free Typert pass (`compile-referenced-projects.ts host-contracts`), without any bundle | None | +| CI node 24 / snapshots and artifacts (`check:ci:consumers`), windows node 24 / build & observational (`check:ci:windows-blocking`) | `build` | Yes, the two required lanes | +| CI node-compat 22.19 | `build:artifacts` (through run-gates) | None | +| CI python runtime | `build-exe-for-python-sdk.ts --build-artifacts` | None | +| PR rehearsals in `e2e.yml` and `release.yml` | `pnpm run build:artifacts --profile official` | None | +| `build-preview-cloudflare.yml` | `pnpm run build:artifacts` | None | +| CI node 24 / benchmarks | `run-ci-bench.ts` (`tsc -b --noCheck`) | None | +| PR rehearsal in `release-vendor.yml` | `compile-referenced-projects.ts host-libraries` | None | +| Non-PR branch of `release-vendor.yml`, `release-vendor-publish.yml` | `build:lib:host` | Host aggregate root program only | + +`lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate read only Typert outputs and package declarations: both aggregates are `noEmit`, oxlint builds its own programs, `doc-typecheck` references only package projects, and none of them reads a bundle. Their prerequisite step therefore skips the Host aggregate check and every bundle; skipping the Host aggregate check and the Desktop bundle alone took it from 159.8 s to 90.9 s (see the measurements below). + +The consumers lane's HMR Web test starts `dev:web` and therefore uses `build:artifacts` inside that lane; the same lane's `build` gate still runs both aggregate checks. CI does not call `build:check` separately: the two required lanes already get the same checks through `build`. The [PR artifact build decision](2026-10-05-pr-artifact-typechecks.md) keeps its required complete builds, benchmark `--noCheck`, job and worker ceilings, serial node-compat Vitest, coverage weights, and pnpm packing checks; its PR artifact build is `build:artifacts`. + +### Measurements + +Prototype conditions: a shared 256 vCPU Linux host (load average 23–70), Node v24.18.0, TypeScript 6.0.3, React type packages hoisted to the root, without the Typert changes of [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md); scope is the package-library build (excluding native and web). Every round starts from `pnpm run clean`, running once each in the order serial, concurrent, `build:lib`, `build:lib`, concurrent, serial; `/usr/bin/time -v` times each step, and summed process-tree RSS is sampled every 250 ms. The prototype runs the aggregate checks as `tsc -b tsconfig.{host,client}.json` once references are up to date, when build mode builds only the aggregate root program. + +| Mode (medians of 2) | Wall (s) | CPU user+sys (s) | Process-tree RSS peak (GiB) | +|---|---|---|---| +| Serial `pnpm run build:lib` | 249.4 | 378.0 | 5.95 | +| Each step run separately in sequence | 255.1 | 373.8 | 5.82 | +| Concurrent by dependency graph | 172.1 | 392.1 | 8.00 | + +Median wall falls from 249.4 s to 172.1 s (−77.3 s, −31%). The two runs of each step separately differ by 28 s, so the overhead of running the steps as separate processes is not resolvable. CPU grows by about 14 s (+3.7%), mostly Host aggregate check user time under contention; the RSS peak grows by about 2.1 GiB where the Host aggregate check (about 4 GiB) overlaps Host tsdown (about 5.7 GiB). The concurrent critical path is host-src → host-bundle → desktop-bundle → client-src → client-check; host-check finishes about 33 s before the end and client-bundle about 21 s before. + +Final measurements: master (the merge base) and this branch run interleaved, each from clean, in a 64-core cgroup; values are medians of 2 samples unless marked n=1. + +| Scenario | master | This branch | CPU user+sys (master → branch) | Process-tree RSS peak (master → branch) | +|---|---|---|---|---| +| Complete build `pnpm run build` | 317.1 s | 169.1 s (−46.7%) | 423.1 → 374.4 s (−11.5%) | 6.11 → 7.53 GiB (+1.42) | +| Serial branch build `DSH_GATE_CONCURRENCY=1 pnpm run build` (n=1) | — | 266.7 s | 389.5 s | 6.27 GiB | +| Artifact build: master `build --artifacts-only`, branch `build:artifacts` | 185.8 s | 153.6 s (−17.3%) | 256.2 → 231.2 s | 6.58 → 6.18 GiB | +| Development iteration: master `build`, branch `build:artifacts` | 317.1 s | 153.6 s (−51.6%) | 423.1 → 231.2 s (−45.4%) | 6.11 → 6.18 GiB | +| `pnpm run build:check` after `build:artifacts` | — | 67.7 s | 144.6 s | 5.33 GiB | +| `pnpm run build:check` no-op rerun | — | 19.4 s | 33.1 s | Not measured | +| Prerequisite of `lint` and `doc-typecheck`: master `build:lib:host`, branch `host-libraries` (since replaced by `host-contracts`, see [local gate speed-ups](2026-10-12-local-gate-speedups.md)) | 159.8 s | 90.9 s (−43.2%) | 234.7 → 136.0 s | Not measured | + +The serial branch sample overlapped other work on the host (742 s of foreign CPU), so its wall likely overstates the serial cost. In the serial build segments (master → branch, one sample each), Host tsdown falls from 22.5 to 11.7 s, Client package compilation from 55.6 to 48.7 s, the Client aggregate check from 68.2 to 35.9 s, and Client tsdown from 13.9 to 10.5 s; the segments sum to 308.5 s and 254.9 s. Those reductions come from [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md); the further reduction from 254.9 s of serial segments to the 169.1 s concurrent build comes from this note's scheduling, which takes the Host aggregate check (about 66 s) off the critical path. In the concurrent build, the critical path is native → host-src → host-bundle → desktop-bundle → client-src → client-check, and host-check ends about 30 s before the last step (one earlier sample). `build:check` runs the Host and Client aggregate checks concurrently, so its wall is the Host check's (about 65 s). + +### Completeness and lossless evidence + +`build` runs the same set of compiler programs as the serial build: host-src and client-src compile every package project of both faces, and host-check and client-check check both aggregate root programs with the same commands as `build:check`. Across the prototype's six rounds, the 11,580 files of `{packages,apps,vendor,native}/**/lib/**` (excluding `*.tsbuildinfo`) are byte-identical between serial and concurrent runs, and after every round `tsc -b --dry tsconfig.host.json tsconfig.client.json` reports no out-of-date project. tsbuildinfo writers are host-src (292), client-src (76, exactly the client-only projects), and one per aggregate check; client-src rewrites no tsbuildinfo or declaration that host-check reads, so the graph has no write race. The fixture tests `checks only the aggregate program in project mode` and `reports missing package declarations without compiling references` show that `tsc -b --dry` treats the tsbuildinfo written by `tsc -p` as up to date and that project mode compiles no reference. In this implementation, the concurrent `build` and `DSH_GATE_CONCURRENCY=1 pnpm run build` produce byte-identical `lib/` (11,584 files), `apps/web/dist` (156 files), and client build record; afterwards `tsc -b --dry` reports all 372 projects of both aggregates up to date, with none to build. + +| Error | `build:artifacts` | `build:check` | `build` | `typecheck` | Required CI lanes | +|---|---|---|---|---|---| +| Package source type error | host-src / client-src | Not reported (reads existing declarations) | host-src / client-src | Reported | Reported | +| Type error only in an aggregate root program's tests, scripts, benchmarks, or website | Not reported | host-check / client-check | host-check / client-check | Reported | consumers, windows-build | +| Package tsconfig configuration error | host-src / client-src | Not reported (`tsc -p` does not validate referenced projects' tsconfig options or syntax) | host-src / client-src | Reported | Reported | +| Typert analysis error | host-bundle | Not reported | host-bundle | Reported (through host-typert) | Reported | +| Missing prerequisite output | — | Compiler diagnostic (TS6305, TS2307) | — | — | — | + +Negative controls: after a type error (TS2322) is added to `scripts/pnpm-invocation.spec.ts`, which only the Host aggregate root program contains, `build:artifacts` exits 0; `build:check` exits 1 with the Host aggregate check failing and the Client aggregate check passing; `build` exits 1 with 8 of 9 steps passing and 1 failing, and writes no client build record. In a scratch fixture whose referenced project's tsconfig has an invalid `target`, a JSON syntax error, or an unknown compiler option, `tsc -p` exits 0, while `tsc -b` reports TS6046, TS5092 with TS1012, and TS5023 respectively and exits 1; when the referenced project lacks `composite`, both report TS6306. + +CI evidence: on PR #6005 both required lanes that run the concurrent `pnpm run build` passed, `node 24 / snapshots and artifacts` (`check:ci:consumers` on `blacksmith-16vcpu-ubuntu-2404`) and `windows node 24 / build & observational` (`check:ci:windows-blocking` on `blacksmith-16vcpu-windows-2025`); the GitHub-hosted 4 vCPU benchmarks lane does not run `build`. The inner concurrency follows `DSH_GATE_CONCURRENCY`: master serial lanes set 1, the consumers lane sets 10 under self-hosted failover, and an unset value selects the available CPU count, capped at the 9 steps. + +### Intentional trade-offs (not lossless) + +- **Some commands skip the aggregate checks:** `build:artifacts`, the launch builds of `dev:web` and `dev:desktop`, and the prerequisite steps of `lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate do not type-check the two aggregate root programs, and these prerequisite steps produce declarations and Typert outputs but no bundle. `build`, `build:check`, `typecheck` (pre-push hook), and the two required CI lanes own completeness; `dev:web` and `dev:desktop` do not refuse to launch because of test type errors. +- **`build:check` does not check freshness:** When sources change after `build:artifacts`, `build:check` checks against stale declarations, including Typert Remote declarations; rerun `build:artifacts` first. The complete `build` is unaffected. +- **Concurrent `build` raises the memory peak:** This branch's complete `build` peaks at 7.53 GiB of process-tree RSS against 6.11 GiB for the master serial build (+1.42 GiB, medians of 2) and 6.27 GiB for the branch's serial build (n=1); CPU meanwhile falls from 423.1 s to 374.4 s because [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md) removes more work than concurrent contention adds. In the prototype (without the Typert changes), concurrent scheduling alone raises the RSS peak by about 2.1 GiB (5.9 → 8.0 GiB) and CPU by about 3.7%. `DSH_GATE_CONCURRENCY` bounds it; under memory pressure use `DSH_GATE_CONCURRENCY=1 pnpm run build`, or run `build:artifacts` and `build:check` separately. In the windows-build lane, `build` runs concurrently with `docs:build`. +- **Passing steps' output is hidden by default:** Only failed steps print their complete output. +- **`tsc -p` does not skip an up-to-date root program:** An immediate rerun of `build:check` without changes still takes 19.4 s and 33.1 s of CPU because both programs are parsed again; `tsc -b` skips an up-to-date root. +- **On many-core cold builds `build:artifacts` is only 15.5 s faster than `build`:** This branch's cold `build` takes 169.1 s and `build:artifacts` 153.6 s (medians of 2), because host-check is off the critical path and client-check is only about 16 s longer than client-bundle plus web. The rest of `build:artifacts`'s saving is CPU and memory: 374.4 → 231.2 s of CPU and 7.53 → 6.18 GiB of peak RSS; on few-core machines and in incremental rebuilds this also turns into wall time. +- **CI invokes public commands in the root manifest:** PR artifact builds invoke `build:artifacts` rather than an internal helper flag; a PR that edits these root manifest scripts activates the separate Node Addon System platform matrix. + +## Alternatives considered + +- **Keep the aggregate checks only in `typecheck`:** `pnpm run build` must stay complete and detect every error. +- **Keep `--artifacts-only` and add a `build:artifacts` alias:** One operation would have two names. +- **Name the commands `build:src` and `build:test`:** `build:artifacts` produces every artifact, including the tsdown bundles, the Desktop bundle, and Web, not only compiled sources; `build:check` checks scripts, benchmarks, and website code as well as tests. +- **Name the commands after compiler terms (`build:b`/`build:p`, `build:project`, `build:emit`) or `build:dist`:** tsc runs in only two artifact steps, and `-p` is an implementation choice for the check steps; TypeScript calls the `tsc -p` side the project; `emit` reads as the emit-only `--noCheck` mode although package projects stay fully checked; package outputs live under `lib/`, and `dist` suggests release packaging. +- **`build:check` runs `tsc -b `:** Build mode compiles out-of-date references, doing `build:artifacts`'s work. +- **`build:check` first completes or validates prerequisite outputs:** It violates each command doing only its own job and adds prerequisite logic; compiler diagnostics already name missing outputs. +- **A new scheduler (shell `&`/`wait`, `concurrently`, turbo/nx):** It duplicates run-gates' failure summary, process-tree termination, and Windows handling. +- **Expand `build` into several gates in CI's run-gates:** `pnpm run build` must be a complete command usable directly locally and by release workflows, and it writes the record. +- **Relax the non-data edges in the artifact chain (native ∥ host-src, desktop-bundle ∥ client-src):** Not measured; the Desktop bundle itself takes only 4.0–4.5 s. +- **Move tests into a new tsconfig:** The existing aggregates already separate package references from the root program. +- **Split the aggregate root programs into sub-programs:** Each sub-program must inject the 242 files carrying module augmentations, CPU grows by 9–57%, and only wall falls (see [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md)). +- **Run `build:check` separately in CI:** The required lanes already get the same checks through `build`; a separate run repeats the work. + +## Consequences + +Development iterations can skip the two aggregate root programs with `build:artifacts` (51.6% faster cold than master's `build`) and complete the checks with `build:check` or `build` before declaring work complete. `build` stays complete: the Host aggregate check is off the critical path, the Client aggregate check runs alongside the Client bundle and web, and a cold build is 46.7% faster than master (including the lossless speed-ups note's changes; medians of 2). The costs are a higher memory peak, a little extra CPU from concurrent contention, less passing output by default, and several development and prerequisite commands that do not check test types (see the trade-offs above). + +Removing or changing `build` in the two required lanes, consumers and windows-build, requires first transferring both aggregate checks to another blocking owner. The typecheck scope and the lint prerequisites generate Typert contracts without bundling ([local gate speed-ups](2026-10-12-local-gate-speedups.md)); the artifact scopes keep generating them inside Host tsdown. + +client-src's dependency on host-bundle guarantees the "Host and Remote artifacts complete before Client compilation" ordering that [Typert Remote method calls](../architecture/2026-08-02-typert-remote-method-calls.md) describes. + +Verification: `scripts/build.spec.ts` covers the type-stripping check, rejection of the removed flag and invalid scopes, the conflict between `--profile` and `--scope check`, scope selection, step dependencies, and the environment; `compile-referenced-projects.spec.ts` covers project mode checking only the aggregate program, the diagnostic for missing declarations, and `tsc -b --dry` accepting the tsbuildinfo that `tsc -p` writes; failure propagation, output buffering, and process-tree termination come from the run-gates scheduler and its tests; `run-gates.spec.ts`, `ci-workflow.spec.ts`, `preview-workflow.spec.ts`, `build-exe-for-python-sdk.spec.ts`, `dev-web.spec.ts`, and `tests/ci-release-selfhosted.spec.ts` pin each caller's command. diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md new file mode 100644 index 0000000000..4929e28574 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md @@ -0,0 +1,157 @@ +# Agent Note: 拆分 build:artifacts 与 build:check,build 并发执行两者 + +Status: implemented + +[English](2026-10-11-build-artifacts-check-split.md) | 中文 + +## 问题 + +`pnpm run build` 串行执行 native addon、包库构建(Host `tsc -b tsconfig.host.json` → Host tsdown → Desktop bundle → Client `tsc -b tsconfig.client.json` → Client tsdown)和 Web 构建。Host 与 Client 聚合配置除了引用各包 project,还各自带有一个 `noEmit` 根程序,覆盖 tests、scripts、benchmarks 与 website。这两个根程序不产出任何文件,也不是后续产物步骤的输入,却位于串行关键路径上:单独计时时 Host 根程序为 68.5 s、Client 根程序为 36.0 s,串行包库构建约为 249 s(2 个样本的中位数)。 + +开发迭代只需要产物,测试与脚本的类型检查在声明完成前才需要。能跳过两个根程序的只有面向 CI 的 `pnpm run build --artifacts-only`;它没有本地开发命令名,也没有只做这两项检查的对应命令。同时,`pnpm run build` 必须保持完整,能检测出所有类型错误。 + +## 决策 + +### 命令 + +每个命令只做自己的工作。 + +| 命令 | 职责 | 步骤 | 调度 | client build record | +|---|---|---|---|---| +| `pnpm run build:artifacts` | 产出 `build` 的全部产物;包 project 照常完整类型检查,不检查两个聚合根程序 | native → host-src → host-bundle → desktop-bundle → client-src → client-bundle → web | 一条线性链 | 开始时删除,全部通过后写入 | +| `pnpm run build:check` | 只类型检查两个聚合根程序 | host-check、client-check | 两步并发,无前置步骤 | 不读写 | +| `pnpm run build` | `build:artifacts` 与 `build:check` 的全部步骤 | 下文依赖图 | run-gates 调度器并发执行 | 开始时删除,全部通过后写入 | + +`build:check` 不检测、不重建、也不校验 `build:artifacts` 的产物;前置产物缺失或过期时,由编译器自身的诊断(例如 TS6305、TS2307)报告,任何命令都不增加前置检查、守卫或回退逻辑。`build:artifacts` 产出的产物与 record 和 `build` 相同,并取代 `--artifacts-only`;该参数与 `compile-referenced-projects.ts` 的 `libraries` 模式已删除,Python executable builder 使用 `--build-artifacts`。`build:official` 仍是 `tsx scripts/build.ts --profile official`,执行完整的 `build`。 + +### 步骤与依赖 + +| 步骤 | 命令 | `build` 中的 `needs` | +|---|---|---| +| native | `pnpm run build:native-system` | — | +| host-src | `node --max-old-space-size=4096 -b ` | native | +| host-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE host`(运行 Typert) | host-src | +| desktop-bundle | `pnpm --filter @deepseek-ai/dsh-desktop run bundle` | host-bundle | +| client-src | ` -b ` | desktop-bundle | +| client-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE client` | client-src | +| web | `pnpm run build:web` | client-bundle | +| host-check | `node --max-old-space-size=4096 -p tsconfig.host.json` | host-src | +| client-check | ` -p tsconfig.client.json` | client-src | + +```text +native → host-src ─┬─ host-check ───────────────────────────────────────────────────────┐ + └─ host-bundle → desktop-bundle → client-src ─┬─ client-check ───────┤ → record + └─ client-bundle → web ┘ +``` + +- client-src 必须在 host-bundle 之后:Client 包导入 `@deepseek-ai/dsh-*/remote`,其类型 `lib/typert.remote-client.d.ts` 由 Typert 写出:这些 scope 在 Host tsdown 中生成,typecheck scope 经 `scripts/generate-typert.ts` 生成。host-check 不读取 Typert 输出,干净的串行构建一直在 Host tsdown 之前完成 Host 聚合检查。 +- 产物链保留 native → host-src 与 desktop-bundle → client-src 这两条非数据依赖;只有两个 check 步骤并行挂接到链上。 +- [`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts) 拥有包库步骤列表 `libraryBuildSteps`、references 参数构造 `referencedProjectsCompilerArgs`(相对路径传参,避开 Windows 命令行长度上限)与聚合检查参数 `aggregateCheckArgs`。vendor 发布演练(`host-libraries`)与 CI benchmark(`--noCheck`)串行执行同一步骤列表。 +- check 步骤使用 project mode(`tsc -p`):它读取 references 已有的声明而不编译任何 reference,incremental 状态写入聚合配置已有的 `tsconfig.{host,client}.tsbuildinfo`。`build` 与 `build:check` 使用同一条命令,因此诊断相同。 + +### 调度、失败与输出 + +- [`scripts/build.ts`](../../../../scripts/build.ts) 拥有步骤表与三个范围,执行复用 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 的 `runAggregate`:每个步骤是一个带 `needs` 的 gate,shell-free 启动、按步骤缓冲输出、fail-fast 进程树终止、Windows `taskkill` 与宿主信号转发都沿用 run-gates 的实现。`build:artifacts` 为 `tsx scripts/build.ts --scope artifacts`,`build:check` 为 `--scope check`,`build` 不带 `--scope`,把两者作为一张图执行;`--profile` 只适用于产生产物的范围,与 `--scope check` 同用时参数解析失败。 +- 并发上限取 `DSH_GATE_CONCURRENCY`,未设置时为可用 CPU 数;依赖图宽度最多为 3(host-check 尚未结束时,client-check 与 client-bundle 可能已开始)。master 串行参考 lane 设置 `DSH_GATE_CONCURRENCY=1`,`build` 在那里按拓扑顺序串行执行,符合[串行参考决策](2026-07-21-serial-cross-platform-ci-reference.zh.md),无需修改 workflow。 +- 缺省(本地)时,失败步骤的所有依赖方记为 skipped,正在运行的步骤和与失败无关的步骤继续运行到结束;执行集合只取决于各步骤的结果,因此一次运行报告所有相互独立的类型错误。`DSH_GATE_FAIL_FAST=1`(所有构建型 PR lane 已设置)时,第一个失败终止其余正在运行步骤的进程树,所有未完成步骤记为 skipped。 +- 失败步骤打印 `== FAILED <步骤> ==`、命令、退出原因与该步骤完整的缓冲输出,结尾汇总列出 failed 与 skipped 步骤;成功步骤只打印 PASS 行,`DSH_GATE_VERBOSE=1` 打印全部输出。任一步骤 failed 或 skipped 时退出码为 1,且不写 client build record。所有步骤继承 `clientBuildProcessEnvironment` 选定的公开 Client 环境。 +- CI 中 `build` 本身是外层 run-gates 的一个 gate;外层 fail-fast 终止会转发到内层调度。`check:node-compat` 与 `check:ci:lint:contracts-ready` 已使用同样的嵌套。 + +### 调用方 + +| 调用方 | 命令 | 聚合根程序检查 | +|---|---|---| +| `build`、`build:official`、`Makefile` `build`、`scripts/publish-npm-baseline.ts`、`apps/desktop/scripts/package-target.ts`、`scripts/dev-bundle-registry.ts`、`release-publish.yml`、`sandbox.yml`、`e2e.yml` 与 `release.yml` 的非 PR 分支 | `build` / `build:official` | 有 | +| `test:web`、`test:web:refresh`、`test:web:perf`、`test:web:stress`、`apps/desktop` `test:updates:local`、`test:bench` / `build:bench` | `build`;`build:bench` 运行 `build:native-system`、`build:lib` 与 benchmark tsdown | 有 | +| `typecheck`(pre-push hook 运行它) | `build.ts --scope typecheck`:host-src 与 host-typert(不打包、只读源码的 Typert 生成 `generate-typert.ts`)并行 → client-src,各 face 包编译后运行 `tsc -b tsconfig.{host,client}.json`;不含任何 bundle | 有 | +| `dev:web`([`scripts/dev-web.ts`](../../../../scripts/dev-web.ts))、`dev:desktop`(`apps/desktop/scripts/dev.ts`) | `build:artifacts` | 启动前无;`dev:web` 启动后的 `tsc -b tsconfig.client.json --watch` 持续检查 Client 聚合根程序 | +| `lint`、`lint:fix`、`doc-typecheck` 与 CI 的 Typert contracts gate 的前置步骤 | Host 包编译与不打包的 Typert 生成并行(`compile-referenced-projects.ts host-contracts`),不含任何 bundle | 无 | +| CI node 24 / snapshots and artifacts(`check:ci:consumers`)、windows node 24 / build & observational(`check:ci:windows-blocking`) | `build` | 有,两个必需 lane | +| CI node-compat 22.19 | `build:artifacts`(经 run-gates) | 无 | +| CI python runtime | `build-exe-for-python-sdk.ts --build-artifacts` | 无 | +| `e2e.yml`、`release.yml` 的 PR 演练 | `pnpm run build:artifacts --profile official` | 无 | +| `build-preview-cloudflare.yml` | `pnpm run build:artifacts` | 无 | +| CI node 24 / benchmarks | `run-ci-bench.ts`(`tsc -b --noCheck`) | 无 | +| `release-vendor.yml` 的 PR 演练 | `compile-referenced-projects.ts host-libraries` | 无 | +| `release-vendor.yml` 的非 PR 分支、`release-vendor-publish.yml` | `build:lib:host` | 仅 Host 聚合根程序 | + +`lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 只读取 Typert 输出与包声明:两个聚合配置都是 `noEmit`,oxlint 自行构建程序,`doc-typecheck` 只引用包 project,它们都不读取 bundle。它们的前置步骤因此跳过 Host 聚合检查与所有 bundle;仅跳过 Host 聚合检查与 Desktop bundle 就使耗时从 159.8 s 降到 90.9 s(见下文测量)。 + +consumers lane 的 HMR Web 测试启动 `dev:web`,因此在该 lane 内使用 `build:artifacts`;同一 lane 的 `build` gate 仍运行两个聚合检查。CI 不单独调用 `build:check`:两个必需 lane 已通过 `build` 获得同一组检查。[PR 产物构建决策](2026-10-05-pr-artifact-typechecks.zh.md)的必需完整构建、benchmark `--noCheck`、job 与 worker 上限、node-compat 串行 Vitest、覆盖率权重与 pnpm 打包检查仍然有效;其中的 PR 产物构建由 `build:artifacts` 执行。 + +### 测量 + +原型测量的条件:256 vCPU 共享 Linux 主机(load average 23–70),Node v24.18.0、TypeScript 6.0.3,React 类型包已提升到根,不含[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md) 的 Typert 改动;范围是包库构建(不含 native 与 web)。每轮从 `pnpm run clean` 开始,按 串行、并发、`build:lib`、`build:lib`、并发、串行 的顺序各运行一次;每个步骤由 `/usr/bin/time -v` 计时,进程树 RSS 每 250 ms 采样求和。原型中的聚合检查以 `tsc -b tsconfig.{host,client}.json` 在 references 已是最新时运行,此时 build mode 只构建聚合根程序。 + +| 方式(2 个样本的中位数) | wall(s) | CPU user+sys(s) | 进程树 RSS 峰值(GiB) | +|---|---|---|---| +| 串行 `pnpm run build:lib` | 249.4 | 378.0 | 5.95 | +| 各步骤依次单独运行 | 255.1 | 373.8 | 5.82 | +| 按依赖图并发 | 172.1 | 392.1 | 8.00 | + +wall 中位数从 249.4 s 降到 172.1 s(−77.3 s,−31%)。各步骤单独运行的两次样本相差 28 s,因此把步骤拆成独立进程的开销无法分辨。CPU 增加约 14 s(+3.7%),主要是争用下 Host 聚合检查增加的 user 时间;RSS 峰值增加约 2.1 GiB,出现在 Host 聚合检查(约 4 GiB)与 Host tsdown(约 5.7 GiB)重叠时。并发时的关键路径是 host-src → host-bundle → desktop-bundle → client-src → client-check;host-check 在结束前约 33 s 完成,client-bundle 在结束前约 21 s 完成。 + +最终测量:master(合并基点)与本分支交替运行,每次均从 clean 开始,在 64 核 cgroup 中运行;除标注 n=1 者外,数值均为 2 个样本的中位数。 + +| 场景 | master | 本分支 | CPU user+sys(master → 本分支) | 进程树 RSS 峰值(master → 本分支) | +|---|---|---|---|---| +| 完整构建 `pnpm run build` | 317.1 s | 169.1 s(−46.7%) | 423.1 → 374.4 s(−11.5%) | 6.11 → 7.53 GiB(+1.42) | +| 本分支串行构建 `DSH_GATE_CONCURRENCY=1 pnpm run build`(n=1) | — | 266.7 s | 389.5 s | 6.27 GiB | +| 产物构建:master `build --artifacts-only`,本分支 `build:artifacts` | 185.8 s | 153.6 s(−17.3%) | 256.2 → 231.2 s | 6.58 → 6.18 GiB | +| 开发迭代:master `build`,本分支 `build:artifacts` | 317.1 s | 153.6 s(−51.6%) | 423.1 → 231.2 s(−45.4%) | 6.11 → 6.18 GiB | +| `pnpm run build:check`(`build:artifacts` 之后) | — | 67.7 s | 144.6 s | 5.33 GiB | +| `pnpm run build:check` 无改动重跑 | — | 19.4 s | 33.1 s | 未测量 | +| `lint` 与 `doc-typecheck` 的前置步骤:master `build:lib:host`,本分支 `host-libraries`(后由 `host-contracts` 取代,见[本地门禁提速](2026-10-12-local-gate-speedups.zh.md)) | 159.8 s | 90.9 s(−43.2%) | 234.7 → 136.0 s | 未测量 | + +本分支串行样本运行时主机上有其他工作(外来 CPU 742 s),因此它的 wall 可能高估串行成本。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;各段合计分别为 308.5 s 与 254.9 s。这些缩短来自[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md);从 254.9 s 的串行各段合计到 169.1 s 的并发构建,其余缩短来自本 Note 的调度,它把 Host 聚合检查(约 66 s)移出关键路径。并发构建的关键路径是 native → host-src → host-bundle → desktop-bundle → client-src → client-check,host-check 在最后一步结束前约 30 s 完成(取自更早的一个样本)。`build:check` 并发运行 Host 与 Client 聚合检查,因此其 wall 等于 Host 聚合检查的 wall(约 65 s)。 + +### 完整性与无损依据 + +`build` 运行的编译器程序集合与串行构建相同:host-src 与 client-src 编译两个 face 的全部包 project,host-check 与 client-check 检查两个聚合根程序,且使用与 `build:check` 相同的命令。原型的 6 轮运行中,`{packages,apps,vendor,native}/**/lib/**`(排除 `*.tsbuildinfo`)的 11,580 个文件在串行与并发之间逐字节一致,每轮之后 `tsc -b --dry tsconfig.host.json tsconfig.client.json` 报告没有过期 project。tsbuildinfo 写入方归属为:host-src 292 个,client-src 76 个(恰好是 client-only project),两个聚合检查各 1 个;client-src 不改写 host-check 读取的任何 tsbuildinfo 或声明,因此该依赖图中不存在写入竞争。fixture 测试 `checks only the aggregate program in project mode` 表明 `tsc -p` 写出的 tsbuildinfo 被 `tsc -b --dry` 视为最新,`reports missing package declarations without compiling references` 表明 project mode 不编译 references。本实现中,并发 `build` 与 `DSH_GATE_CONCURRENCY=1 pnpm run build` 产出逐字节一致的 `lib/`(11,584 个文件)、`apps/web/dist`(156 个文件)与 client build record;之后 `tsc -b --dry` 对两个聚合配置报告 372 个 project 全部为最新,没有需要构建的 project。 + +| 错误 | `build:artifacts` | `build:check` | `build` | `typecheck` | 必需 CI lane | +|---|---|---|---|---|---| +| 包源码类型错误 | host-src / client-src | 不报告(读取已有声明) | host-src / client-src | 报告 | 报告 | +| 只属于聚合根程序的测试、脚本、benchmark、website 类型错误 | 不报告 | host-check / client-check | host-check / client-check | 报告 | consumers、windows-build | +| 包 tsconfig 配置错误 | host-src / client-src | 不报告(`tsc -p` 不校验被引用 project 的 tsconfig 选项与语法) | host-src / client-src | 报告 | 报告 | +| Typert 分析错误 | host-bundle | 不报告 | host-bundle | 报告(经 host-typert) | 报告 | +| 前置产物缺失 | — | 编译器诊断(TS6305、TS2307) | — | — | — | + +负对照:在只属于 Host 聚合根程序的 `scripts/pnpm-invocation.spec.ts` 中加入类型错误(TS2322)后,`build:artifacts` 以 0 退出;`build:check` 以 1 退出,其中 Host 聚合检查失败、Client 聚合检查通过;`build` 以 1 退出,9 个步骤中 8 个通过、1 个失败,且不写 client build record。在临时 fixture 中,被引用 project 的 tsconfig 含无效 `target`、JSON 语法错误或未知编译选项时,`tsc -p` 以 0 退出,`tsc -b` 分别报告 TS6046、TS5092 与 TS1012、TS5023 并以 1 退出;被引用 project 缺少 `composite` 时,两者都报告 TS6306。 + +CI 证据:PR #6005 上,两个运行并发 `pnpm run build` 的必需 lane 均通过:`node 24 / snapshots and artifacts`(`check:ci:consumers`,运行于 `blacksmith-16vcpu-ubuntu-2404`)与 `windows node 24 / build & observational`(`check:ci:windows-blocking`,运行于 `blacksmith-16vcpu-windows-2025`);GitHub 托管的 4 vCPU benchmarks lane 不运行 `build`。内层并发取 `DSH_GATE_CONCURRENCY`:master 串行 lane 设为 1,consumers lane 在 self-hosted failover 时设为 10,未设置时取可用 CPU 数,且不超过 9 个步骤。 + +### 有意的取舍(非无损) + +- **部分命令跳过聚合检查:** `build:artifacts`、`dev:web` 与 `dev:desktop` 的启动构建,以及 `lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 的前置步骤,都不类型检查两个聚合根程序,这些前置步骤只产出声明与 Typert 输出,不打包。完整性由 `build`、`build:check`、`typecheck`(pre-push hook)与两个必需 CI lane 负责;`dev:web` 与 `dev:desktop` 不会因测试类型错误而拒绝启动。 +- **`build:check` 不检查新鲜度:** 在 `build:artifacts` 之后修改源码时,`build:check` 基于旧声明(包括 Typert Remote 声明)检查;需要先重跑 `build:artifacts`。完整 `build` 不受影响。 +- **并发 `build` 提高内存峰值:** 本分支完整 `build` 的进程树 RSS 峰值为 7.53 GiB,master 串行构建为 6.11 GiB(+1.42 GiB,2 个样本的中位数),本分支串行构建为 6.27 GiB(n=1);同时 CPU 从 423.1 s 降到 374.4 s,因为[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md) 省去的工作多于并发争用增加的工作。原型(不含 Typert 改动)中,单独的并发调度使 RSS 峰值增加约 2.1 GiB(5.9 → 8.0 GiB)、CPU 增加约 3.7%。上限由 `DSH_GATE_CONCURRENCY` 控制;内存紧张时使用 `DSH_GATE_CONCURRENCY=1 pnpm run build`,或分别运行 `build:artifacts` 与 `build:check`。windows-build lane 中 `build` 与 `docs:build` 同时运行。 +- **成功步骤的输出默认不显示:** 只有失败步骤打印完整输出。 +- **`tsc -p` 不跳过已是最新的根程序:** 无改动时立即重跑 `build:check` 仍需 19.4 s 与 33.1 s CPU,因为两个程序都要重新解析;`tsc -b` 会跳过已是最新的根。 +- **多核冷启动时 `build:artifacts` 只比 `build` 快 15.5 s:** 本分支冷启动 `build` 为 169.1 s,`build:artifacts` 为 153.6 s(2 个样本的中位数),因为 host-check 不在关键路径上,client-check 只比 client-bundle 与 web 之和长约 16 s。`build:artifacts` 的其余收益是 CPU 与内存:CPU 从 374.4 s 降到 231.2 s,RSS 峰值从 7.53 GiB 降到 6.18 GiB;在少核机器与增量重建中,这部分也转化为 wall。 +- **CI 调用根 manifest 中的公开命令:** PR 产物构建调用 `build:artifacts`,而不是内部 helper 参数;修改根 manifest 中这些 script 的 PR 会激活独立的 Node Addon System 平台矩阵。 + +## 考虑过的替代方案 + +- **只在 `typecheck` 中保留聚合检查:** `pnpm run build` 必须完整,能检测出所有错误。 +- **保留 `--artifacts-only` 并新增 `build:artifacts` 别名:** 同一件事有两个名字。 +- **命名为 `build:src` 与 `build:test`:** `build:artifacts` 产出全部产物,包括 tsdown bundle、Desktop bundle 与 Web,而不只是编译后的源码;`build:check` 除测试外还检查脚本、benchmarks 与 website 代码。 +- **按编译器术语命名(`build:b`/`build:p`、`build:project`、`build:emit`)或命名为 `build:dist`:** tsc 只出现在两个产物步骤中,`-p` 只是 check 步骤的实现选择;TypeScript 把 `tsc -p` 一侧称为 project;`emit` 容易被理解为只 emit 的 `--noCheck` 模式,而包 project 仍完整检查;包产物位于 `lib/`,`dist` 则让人联想到发布打包。 +- **`build:check` 使用 `tsc -b `:** build mode 会编译过期的 references,即执行 `build:artifacts` 的工作。 +- **`build:check` 先补齐或校验前置产物:** 违背每个命令只做自己的工作,并增加前置逻辑;编译器诊断已经指明缺失的输出。 +- **新的调度实现(shell `&`/`wait`、`concurrently`、turbo/nx):** 重复 run-gates 已有的失败汇总、进程树终止与 Windows 处理。 +- **在 CI 的 run-gates 中把 `build` 展开为多个 gate:** `pnpm run build` 必须是本地与 release workflow 可直接使用的完整命令,record 也由它写入。 +- **放宽产物链内的非数据依赖(native ∥ host-src、desktop-bundle ∥ client-src):** 未测量;Desktop bundle 本身只占 4.0–4.5 s。 +- **把测试移入新的 tsconfig:** 现有聚合配置已经把包 references 与根程序分开。 +- **把聚合根程序拆成多个子程序:** 每个子程序都要注入 242 个含模块增强的文件,CPU 增加 9–57%,只降低 wall(见[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md))。 +- **CI 单独运行 `build:check`:** 必需 lane 已通过 `build` 获得同一组检查,再单独运行是重复工作。 + +## 后果 + +开发迭代可以用 `build:artifacts` 跳过两个聚合根程序(冷启动比 master 的 `build` 快 51.6%),在声明完成前用 `build:check` 或 `build` 补齐检查。`build` 保持完整:Host 聚合检查不在关键路径上,Client 聚合检查与 Client bundle、web 并行,冷启动比 master 快 46.7%(含无损提速 Note 的改动,2 个样本的中位数)。代价是更高的内存峰值、并发争用带来的少量额外 CPU、默认更少的成功输出,以及多个开发与前置命令不检查测试类型(见上文取舍)。 + +删除或改变 consumers 与 windows-build 两个必需 lane 的 `build`,必须先把两个聚合检查转交另一处阻塞型 owner。typecheck scope 与 lint 类前置步骤不打包即生成 Typert 约定([本地门禁提速](2026-10-12-local-gate-speedups.zh.md));产物 scope 仍在 Host tsdown 中生成。 + +[Typert Remote 方法调用](../architecture/2026-08-02-typert-remote-method-calls.zh.md)描述的“Client 编译前先完成 Host 与 Remote 产物”由 client-src 对 host-bundle 的依赖保证。 + +验证:`scripts/build.spec.ts` 覆盖类型剥离检查、已删除参数与非法范围的拒绝、`--profile` 与 `--scope check` 的冲突、范围选择、步骤依赖与环境;`compile-referenced-projects.spec.ts` 覆盖 project mode 只检查聚合程序、缺失声明的诊断,以及 `tsc -b --dry` 接受 `tsc -p` 写出的 tsbuildinfo;失败传播、输出缓冲与进程树终止沿用 run-gates 的调度实现及其测试;`run-gates.spec.ts`、`ci-workflow.spec.ts`、`preview-workflow.spec.ts`、`build-exe-for-python-sdk.spec.ts`、`dev-web.spec.ts` 与 `tests/ci-release-selfhosted.spec.ts` 固定各调用方的命令。 diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml new file mode 100644 index 0000000000..41ef49d183 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for 2026-10-11-lossless-build-speedups.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md +/agent-note-build-speed-ups-that-keep-outputs-unchanged: + en: 48682b646b8f993a + zh: 4c26cb4aab5cd08c +/agent-note-build-speed-ups-that-keep-outputs-unchanged/problem: + en: 46551eb7e33c40ca + zh: f8c1fc063fa77062 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision: + en: 3948d6fc3fabf32e + zh: c3b8dcdfe8168275 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/measurement-conditions: + en: 2ab844073ec7c336 + zh: b25e39f2f80b0d7e +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/1-only-the-root-manifest-declares-react-type-packages: + en: 1101c94d04f9c7e9 + zh: 413b7b15f98a5f62 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/2-typert-selects-packages-by-manifest-first: + en: 1ae4b65792db0975 + zh: a11c0d90842a3b55 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/3-typert-does-not-expand-aggregate-include: + en: 0c8ab2d412113914 + zh: 01ba4f5042422d77 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/4-typert-parses-package-tsconfigs-lazily: + en: cd6e05c61a7cc495 + zh: 480d2d85d78d2d7f +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/5-the-typert-workspace-root-also-requires-packages: + en: 8b89436df201141c + zh: 2fab82419c8ba704 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/6-typert-generates-in-a-worker-thread: + en: 0362483a79c07881 + zh: d84a2390b49c4012 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/7-the-typert-transform-hook-filters-by-module-id: + en: b5ad6b5506c150d5 + zh: 25f09e4ab74696ad +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/8-client-tsdown-presets-read-the-manifest-from-the-package-directory: + en: 80c2ddc5fdff0b5f + zh: f5ce309a2e88ff4f +/agent-note-build-speed-ups-that-keep-outputs-unchanged/alternatives-considered: + en: 4372c82ef0c944dd + zh: 5fb70d499baadea7 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/consequences: + en: 93a96d3ed398f348 + zh: 93b7c7405be981a6 diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md new file mode 100644 index 0000000000..021fe5b241 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md @@ -0,0 +1,136 @@ +# Agent Note: Build speed-ups that keep outputs unchanged + +Status: implemented + +English | [中文](2026-10-11-lossless-build-speedups.zh.md) + +## Problem + +A cold build carries three fixed costs that do not affect output contents: + +- **JSX signatures in the Client test aggregate program.** The Check phase of `tsc -p tsconfig.client.json` takes 59.1 s, more than half of it computing the `@types/react` module specifier for every JSX file that imports React. TypeScript's `computeModuleSpecifiers` visits every symlink location of that declaration file and scans all 491 tsconfig `paths` entries at each location. pnpm creates one `node_modules/@types/react` link for each workspace package that declares `@types/react` itself; 66 manifests declare it, so the Client test aggregate program sees 65 locations. Counters measured 88 JSX files × 2 modules (`index.d.ts`, `jsx-runtime.d.ts`) × 65 locations: 11,457 `tryGetModuleNameFromPaths` calls and 5,623,782 entry scans, 32.6 s in total. A dose-response run at five truncation levels of the visible location count N (N = 1 to 65; Check 26.92 s to 58.40 s) fits Check ≈ 26.9 + 0.494·N s. +- **Typert inside the Host tsdown pass.** The Host tsdown pass takes about 21.6–22.6 s. Typert discovers and generates artifacts synchronously inside the first `writeBundle`; the CPU profile shows it blocking the tsdown main thread for about 12.4 s while every package build waits. Discovery takes 8.06 s: it parses all 362 package tsconfigs referenced by the two face aggregates (about 3.8 s), expands the Host aggregate's 2,064 literal `include` entries on every parse (1.6 s; the analyzer never reads those file names), and walks the public-export surface of 322 registrations (2.7–3.2 s). Only the 28 packages that declare a Typert export produce Host artifacts. +- **Manifest lookup in the Client tsdown pass.** The Client tsdown pass takes about 11.7 s with a saturated main thread (7.1% idle); 34.5% of it (3.95 s) is `workspaceManifest`: every package config runs `globSync('packages/*/*/package.json')` for its package name and parses manifests until the name matches. The module-level cache has no effect because tsdown's native config loader imports every package config through `import-without-cache`, creating a fresh module instance per config. Each glob takes 40–125 ms and runs inside the `neverBundle`/`alwaysBundle` callbacks of `tsdown:deps`, blocking every build. + +#5849 merged Host fixture type narrowing and Typert memoization; this note's baseline is the master that follows it. + +## Decision + +The following eight speed-ups change neither the bytes of `lib/` and `apps/web/dist` outputs nor the set of type diagnostics that repository builds report. The only change in diagnostic ownership comes from item 4: Typert does not validate tsconfigs of packages it does not analyze, and only `tsc -b` reports those errors. [The build split note](2026-10-11-build-artifacts-check-split.md) records the command split and concurrent scheduling. + +| # | Decision | Affected stage | Measured median | Outputs and diagnostics | Intended behavior difference | +|---|---|---|---|---|---| +| 1 | Only the root manifest declares React type packages | Client type checking | Test aggregate Check 59.1 → 26.3 s | Program file count and diagnostics unchanged | Package manifests do not list React type packages | +| 2 | Typert selects packages by manifest first | Host tsdown | 22.62 → 19.94 s | Byte-identical | None | +| 3 | Typert does not expand aggregate `include` | Host tsdown | 19.94 → 16.99 s | Byte-identical; registration inventory identical | None | +| 4 | Typert parses package tsconfigs lazily | Host tsdown | 16.99 → 16.25 s (within noise); Typert discovery 5.9–7.1 → 0.36–0.40 s | Byte-identical; registration inventory identical | Only `tsc -b` reports tsconfig errors in unanalyzed packages | +| 5 | The Typert workspace root also requires `packages/` | Host tsdown | Within noise | Byte-identical | Dual-face packages get artifacts in `package` mode | +| 6 | Typert generates in a Worker Thread | Host tsdown | 15.39 → 10.25 s | Byte-identical; failures still fail tsdown | Host tsdown max RSS about +0.6 GiB | +| 7 | The Typert `transform` hook filters by module id | Host tsdown | Within noise; hook calls 1,566 → 195 | Byte-identical | None | +| 8 | Client tsdown presets read the manifest from the package directory | Client tsdown | 12.78 → 8.73 s | Byte-identical; external decisions identical | The presets' first argument is the package directory | + +The published `./tsdown` export `typertPlugin()` changes with items 5–7 (`transform` as `{ filter, handler }`, an asynchronous `writeBundle`, and a workspace root that must contain `packages/`); rolldown and tsdown consumers accept both hook forms, and the analyzer already required `/packages`, so no external workspace that worked before stops working. + +### Measurement conditions + +All numbers come from one shared Linux host (256 vCPU, 4 TB memory, load average 23–111 during runs) with Node v24.18.0, TypeScript 6.0.3, and tsdown 0.22.2 (rolldown 1.1.1). + +- **Host tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host`, deleting every `lib/typert.*` before each run; variants run interleaved and sequentially, 3–4 samples per group. Items 2–4 come from the first round (starting from the baseline) and items 5–7 from the second round (starting from the code with items 2–4). Output comparison covers SHA-256 of the 743 top-level files of `packages/*/*/lib`, `apps/*/lib`, and `vendor/*/lib` (140 of them Typert artifacts). Medians of two groups running identical code differ by 0–0.11 s; samples within a group spread by up to 2.5 s. +- **Client test aggregate:** `tsc -p tsconfig.client.json --extendedDiagnostics` with prebuilt references and a fresh tsBuildInfo file; six samples of identical code spread by 3.8 s in Check (about 6%). +- **Client tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client` on a built tree. The item 8 prototype ran three rounds in rotated order with a baseline spread of 1.56 s; the final implementation and the original ran interleaved, three samples each. Output comparison covers every file of `{packages,apps,vendor,native}/**/lib/**`, excluding `*.tsbuildinfo`. + +### 1. Only the root manifest declares React type packages + +- **Purpose:** Reduce the number N of `@types/react` locations that the Client test aggregate program sees from 65 to 1, removing module-specifier work that grows linearly with N. +- **Change:** The root `package.json` declares `@types/react` `~18.3.1` and `@types/react-dom` `~18.3.0` in `devDependencies`, the ranges every package used before; manifests under `apps/*`, `packages/*/*`, and `vendor/*` declare neither in any dependency section. TypeScript resolves the root `node_modules/@types/react` from every package. Packages still declare runtime `react` and `react-dom` under the [Client dependency declaration rules](../../../../packages/client/AGENTS.md#dependency-declaration); `packages/client/ui-slots`, which references React only through `import type`, declares `react` in `devDependencies`. pnpm `dedupeDirectDeps` stays off. Lockfile changes are confined to the importers section, plus one reordered `papaparse` entry with the same integrity. +- **Regression guard:** [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) runs in `hygiene` and CI. It requires the root manifest's `devDependencies` to declare both packages and rejects either one in the `dependencies`, `devDependencies`, `optionalDependencies`, or `peerDependencies` of any manifest under `apps/*`, `packages/*/*`, or `vendor/*`; the diagnostic names the entry to delete and links Client dependency declaration rule 8. The rule matches only these two exact names; other `@types/*` packages remain DefinitelyTyped type providers. Dependency-section inference does not treat these two packages as type providers for `react`/`react-dom`: when a package lacks a `react` declaration, the verifier reports `react` as missing and `--fix` refuses to repair it until a version range is declared; `--fix` never writes back or deletes the two packages. [`verify-package-dependencies.spec.ts`](../../../../scripts/verify-package-dependencies.spec.ts) covers rejection in each dependency section, a root manifest missing the declarations, the accepted layout, and the type-provider exclusion. +- **Performance:** + + | Measurement | Per-package declarations (N = 65) | Root manifest declaration (N = 1) | + |---|---|---| + | Client test aggregate Check | 59.08 s (median of 3) | 26.28 s (median of 5), −56% | + | Client test aggregate wall | 68–71 s | 33.6–34.9 s | + | Client test aggregate user CPU | 79–83 s | 44.5–45.3 s | + | Check sum of the 76 client-only package projects | 34.6 s (median of 2) | 26.5 s (median of 2), −23% | + | Wall of the 76 client-only package projects | 55.7 s (median of 2) | 44.5 s (median of 2) | + +- **Why it is lossless:** The 66 package-level links and the root link share one realpath (`.pnpm/@types+react@18.3.31`), so the program reads the same declarations; the program keeps 2,613 files, and `tsc -b --force tsconfig.client.json` exits 0 without diagnostics. `devDependencies` are not installed with published packages, so the [published dependency faces](2026-08-26-published-dependency-faces.md) conclusion that independent TypeScript consumers install referenced type packages themselves is unchanged; runtime resolution never reads `@types/*`. +- **Trade-off:** Package manifests do not list their own React type dependency, and every workspace package shares one React types version; a package that needs a different version requires a new Agent Note overturning this item. The check covers only `apps/*`, `packages/*/*`, and `vendor/*`; other pnpm members such as `website`, `benchmarks`, `native/system`, and `python/sdk-runtime` are out of scope, and none of them currently declares React type packages. + +### 2. Typert selects packages by manifest first + +- **Purpose:** Workspace mode generates artifacts only for packages that declare a Typert export, so it skips the surface walk for every other candidate. +- **Change:** `WorkspaceAnalyzer.discoverPackages(includeManifest?)` and `WorkspaceTypertGenerator.discover(faces?, includeManifest?)` accept an optional `package.json` predicate applied before the surface walk; a rejected package has neither its sources read nor its project config parsed. `WorkspaceTypertGenerator.generateContributors(faces?)` passes `hasTypertExport` (`exports` contains `./typert`, `./client/typert`, or `./remote`). Callers that pass no predicate (the Cordis catalog, `generate()` without packages) behave as before. +- **Performance:** Host tsdown 22.62 s → 19.94 s (medians of 3). +- **Why it is lossless:** The predicate is the filter previously applied after discovery, and a package it rejects never produced artifacts; the 743 output files are byte-identical except the Typert generator's own `lib/index.js`, whose source is this change. + +### 3. Typert does not expand aggregate `include` + +- **Purpose:** The analyzer reads only an aggregate's compiler options and project references; expanding `include` only fills `fileNames`, which it never uses. +- **Change:** `WorkspaceCaches.aggregate(path)` returns and caches `AggregateConfig { path, options, projectReferences }`. An aggregate that declares its own `references` is parsed with a `ParseConfigHost` whose `readDirectory` returns an empty list; an aggregate without `references` is expanded as usual. +- **Performance:** Host tsdown 19.94 s → 16.99 s (medians of 3). +- **Why it is lossless:** TypeScript reports TS18003 (no inputs) only for a config with neither `files` nor `references`, so an aggregate that declares `references` yields the same diagnostics, options, and references without expansion. The registration inventory dumped after forcing every lazy config (408 registrations, including `fileNames` and options, plus the aggregates' options and references) is byte-identical to the baseline, and the outputs are byte-identical. The tests `reads aggregate options and references without expanding include patterns` and `reports an aggregate without references or inputs` pin both kinds of config. + +### 4. Typert parses package tsconfigs lazily + +- **Purpose:** Parse only the tsconfigs of packages selected for analysis, not every package that an aggregate references. +- **Change:** `PackageRegistration.config` is a getter over `caches.config(configPath)` that parses on first read. The registration inventory keeps its order, faces, names, roots, and export subpaths. +- **Performance:** Host tsdown 16.99 s → 16.25 s (medians of 3), within the 2.5 s within-group spread. Together, items 2–4 reduce Typert discovery in an isolated benchmark from 5.9–7.1 s to 0.36–0.40 s, tsconfig parses from 362 to 28, surface walks from 322 to 28, and total Typert time from 9.7–11.8 s to 4.4–4.5 s. +- **Why it is lossless:** The inventory comparison is the one in item 3 (byte-identical), and the outputs are byte-identical. The test `reads only the project configs of manifests selected for discovery` pins that only selected configs are parsed. +- **Trade-off (not lossless):** Typert does not parse tsconfigs of packages it does not analyze, so a syntax or configuration error in such a tsconfig does not fail Typert. In the same build, the Host package compilation `tsc -b` that runs before the Host bundle and the Client package compilation `tsc -b` parse every referenced tsconfig and reject those errors; the `PackageRegistration.config` JSDoc and the [Typert generator README](../../../../packages/typert/generator/README.md) state this ownership. A consumer that runs Typert without `tsc -b` does not receive those diagnostics. + +### 5. The Typert workspace root also requires `packages/` + +- **Purpose:** Correct workspace-root resolution. 24 dual-face packages carry their own `tsconfig.host.json` leaf; searching upward for `tsconfig.host.json` alone took each of them as a root, so workspace mode ran one empty discovery per such package per build, and `package` mode silently produced no artifacts for dual-face packages. +- **Change:** The workspace root is the nearest ancestor of the bundle output that contains both `tsconfig.host.json` and `packages/`. +- **Performance:** Host tsdown 14.82 s → 15.39 s (medians of 3), within noise. +- **Why it is lossless:** The repository root [`tsdown.config.ts`](../../../../tsdown.config.ts) uses only workspace mode, and the 743 output files are byte-identical. The test `resolves the workspace root above a dual-face package leaf` pins the rule. +- **Behavior difference:** A `package`-mode consumer building a dual-face package receives that package's Typert artifacts; repository builds do not use `package` mode. + +### 6. Typert generates in a Worker Thread + +- **Purpose:** Overlap Typert analysis with bundling on the tsdown main thread instead of blocking every package build inside the first `writeBundle`. +- **Change:** [`src/worker.ts`](../../../../packages/typert/generator/src/worker.ts) is the Worker Thread entry; it runs `generateContributors(faces)` and posts one `artifacts` or `failure` message. In workspace mode, the build's first `buildStart` locates the workspace root from `options.cwd` and starts generation; `writeBundle` is asynchronous, the first finishing bundle claims emission and awaits the result, and later bundles return immediately. An analysis error, a worker crash or early exit, a malformed or undeserializable response, or a worker that cannot be terminated fails the claiming bundle with a `TypertWorkerError` naming the workspace and cause, which fails the whole tsdown pass; the worker is terminated before the outcome settles, and an unclaimed outcome never becomes an unhandled rejection. Built output starts the worker from `./worker.js`; source runs (tests) load `worker.ts` through `tsx/esm/api`. `package` mode still generates on the main thread. +- **Performance:** Host tsdown 15.39 s → 10.25 s (medians of 3). Profile of the final configuration (items 2–7): main thread 10.1 s, first `buildStart` at 4.18 s (tsdown config resolution), `writeBundle` on the main thread only 0.37 s; worker 5.0 s (module load about 0.4 s, discovery 0.28 s, generation 3.88 s including `createProgram` 2.21 s), ending at about 9.3 s. Host tsdown wall is about max(bundling, 4.2 s + Typert). +- **Why it is lossless:** All 54 second-round runs exit 0, and each run's 743 output files match the baseline except the generator's own `lib/index.js`. The test `writes the artifacts in-process generation produces` compares worker and in-process artifacts; in a scratch workspace with one package's `files` broken, tsdown exits 1 within 2.4 s, reporting `TypertWorkerError` with the worker's `TypertAnalysisError`, and writes no artifacts. The `typertPlugin workspace mode` test group covers each failure path. +- **Trade-off:** The worker heap is about 300–420 MiB; the final configuration's median Host tsdown max RSS is 6,296–6,408 MiB against 5,676 MiB at baseline. Under heavy shared-host load the worker can land on the critical path: one sample took 21.91 s with the artifact-owning package build waiting about 14 s for the worker, not reproduced in 10 later samples; at load average about 100, two runs took 17.86 s and 14.3 s. The worker also completes under a 1,024 MiB heap limit, so GC pressure is not the cause; attribution to host contention is unproven. + +### 7. The Typert `transform` hook filters by module id + +- **Purpose:** Stop every module from crossing from rolldown into the JavaScript `transform` hook only to be rejected by an extension check. +- **Change:** `transform` declares `filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ }`, and its handler still checks the id with the same regular expression. +- **Performance:** Hook calls per Host build fall from 1,566 to 195 (195 TypeScript modules, 4 of them with decorators). Wall medians are 11.07 s (3 samples) and 9.98 s (4 samples) against 10.25 s for item 6, while the identical-code control groups give 11.07 s and 9.87 s; the difference is within noise. +- **Why it is lossless:** The regular expression uses only ASCII constructs and matches the same id set in rolldown's Rust regex engine and in JavaScript; the handler applies the same check, so hosts that ignore filters behave the same. Outputs are byte-identical; the test `filters transform calls to TypeScript module ids` pins the filter. + +### 8. Client tsdown presets read the manifest from the package directory + +- **Purpose:** Each package config reads only its own `package.json`, removing the glob over every manifest by package name. +- **Change:** The first argument of `clientBundle`, `staticLinked`, and `clientLibrary` in [`packages/client/tsdown.client.ts`](../../../../packages/client/tsdown.client.ts) is the package directory, normally the config file's `import.meta.dirname`; the preset reads `/package.json` once and uses its `name` as the plugin id and diagnostic name; when that manifest is missing, unreadable, or has no `name`, config loading fails with an error naming the path and cause. 82 package configs call these presets. The presets keep no module-level manifest cache; the Node half's production-dependency external list is computed from that manifest on first resolve and cached in that config's closure. +- **Performance:** Client tsdown falls from 12.78 s to 8.73 s (medians of 3), −4.05 s (−31.7%), with disjoint sample ranges; user CPU falls from 26.1 s to 25.2 s and sys from 5.9 s to 4.9 s, so the saving comes mainly from unblocking the main thread rather than from less total CPU. The prototype (one glob building a name index) measured 11.72 s → 7.46 s (−36%). In Host tsdown only 5 `hostPhase` packages reach the lookup; one sample pair measured 11.92 s → 10.71 s, with low confidence. +- **Why it is lossless:** The 11,566 lib files are byte-identical across 11 runs. After the 82 call sites switched to package directories, no resolved package name differs from the name previously written at the call site. An in-process comparison of the external decisions for 82 configs × 3 faces × 1,600 specifiers matches the original implementation exactly; a negative control with one extra external changes 75 decisions. `client-bundle-purity.spec.ts` covers these manifest errors. +- **Behavior difference:** The presets' calling convention changes from a package name to a package directory. + +## Alternatives considered + +- **Enable pnpm `dedupeDirectDeps` (item 1):** It applies to every direct dependency and changes the link layout of every workspace package, a global side effect. +- **Patch TypeScript (item 1):** An upstream-style patch (precomputed candidate endings and cached normalized `paths` patterns) lowers Check by 31.9 s, comparable to the hoist; on the hoisted tree it adds nothing (references Check 25.76 s against 26.47 s) and must be maintained across every TypeScript upgrade. +- **Alias `react` in tsconfig `paths` (item 1):** tsx, vitest, and vite also read `paths`, so the alias changes their resolution of runtime `react`. +- **Narrow `paths` to each face's reference closure (item 1):** The 328-entry version that keeps wildcard keys checks in 55.73 s against 59.08 s (one sample), within noise; the version without wildcard keys produces 18 diagnostics. +- **Split the Client test aggregate program (item 1):** Every sub-program must inject the 242 files carrying module augmentations to pass, and the visible location count stays at 33–49; splitting into 2, 4, or 8 groups leaves the Check sum nearly flat while user CPU rises by 9%, 30%, and 57%, lowering only wall, and the 4- and 8-group splits each produce 1–2 diagnostics. +- **Cache resolution results and make the discovery queue O(1) (Typert):** After items 2–4 only 28 surface walks remain, about 0.18 s, below noise. +- **Move Typert into a separate build step in artifact builds (Typert):** After item 6 the worker already overlaps bundling, leaving at most about 1 s. Type checks, which need no bundle, run a source-only generation step ([local gate speed-ups](2026-10-12-local-gate-speedups.md)). +- **Filter `transform` by code content (item 7):** rolldown interprets `^` and `\s` with Rust regex semantics, which differ from JavaScript on lone CR, U+2028, and BOM, so a decorated module could be missed. +- **Build a process-wide manifest index on `globalThis` (item 8):** The prototype saves about as much time as item 8, but it is process-global state without an owner, violating the shared-state ownership rule in the [package rules](../../../../packages/AGENTS.md). +- **Disable tsdown's gzip report (item 8):** `--no-report` lowers Client tsdown by 0.95 s, within the 1.56 s baseline spread. +- **Add rolldown filters to DSH `resolveId`/`load` hooks (item 8):** Lowers it by 1.16 s, within noise. +- **Memoize the input-isolation check per module id (item 8):** Lowers it by 0.93 s, within noise; on top of the manifest index it saves only another 0.18 s. + +## Consequences + +Client type checking spends substantially less CPU and wall time, and the Host and Client tsdown passes take substantially less wall time, without changing runtime resolution, published installation semantics, or any output byte. In the serial build segments (master → branch, one sample each), Host tsdown falls from 22.5 to 11.7 s, Client package compilation from 55.6 to 48.7 s, the Client aggregate check from 68.2 to 35.9 s, and Client tsdown from 13.9 to 10.5 s; the serial segments sum to 308.5 s on master and 254.9 s on the branch (see [the build split note](2026-10-11-build-artifacts-check-split.md)). Host tsdown lies on the critical path of both `build` and `build:artifacts`, Client tsdown on that of `build:artifacts`, and the Client aggregate check is the last step of the `build` critical path. + +Costs: every workspace package shares one React types version; Typert does not validate tsconfigs of packages it does not analyze, and `tsc -b` owns those errors; Host tsdown max RSS grows by about 0.6 GiB, and under heavy shared-host load the worker can become Host tsdown's critical path. + +Reevaluation conditions: remeasure item 1 when migrating to TypeScript 7, and reconsider per-package declarations if the location count no longer affects Check time. tsdown's own config loading still takes 1.8–2.3 s per Client build (one uncached module instance per config) and needs an upstream change. The first `buildStart` arrives about 4.2 s into the Host build; starting the worker at plugin construction with an explicit root would start Typert earlier. + +Verification: the Typert generator's `tsdown-plugin.spec.ts`, `tsdown-plugin-worker.spec.ts`, and `type-model.spec.ts` pin the behavior of items 2–7; the generator tests report 209 passed and 28 skipped, and `tsc -b tsconfig.host.json` exits 0. The repository coverage gate excludes `packages/typert/*/src/**`; under a scratch coverage config, `tsdown-plugin.ts` reaches 100% statements, branches, functions, and lines. Five negative controls (the old root rule, eager package configs, an expanded aggregate, no emission claim, a skipped worker termination) each fail the test that guards them; the real tsdown failure path exits 1 within 2.4 s and reports `TypertWorkerError`. Item 1: `verify-package-dependencies` exits 0 (74 packages), its spec passes 47/47, `verify-client-packages` exits 0, `tsc -b tsconfig.client.json` reports no diagnostics, and the Client aggregate Check takes 26.48 s. Item 8: all 72 focused specs pass. diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md new file mode 100644 index 0000000000..7ccfe2d3c9 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md @@ -0,0 +1,136 @@ +# Agent Note: 不改变产物的构建提速 + +Status: implemented + +[English](2026-10-11-lossless-build-speedups.md) | 中文 + +## 问题 + +冷启动构建中有三处与产物内容无关的固定开销: + +- **Client 测试聚合程序的 JSX 签名。** `tsc -p tsconfig.client.json` 的 Check 阶段为 59.1 s,其中一半以上用于为每个导入 React 的 JSX 文件计算 `@types/react` 的 module specifier。TypeScript 的 `computeModuleSpecifiers` 遍历该声明文件的每个符号链接位置,并在每个位置扫描全部 491 条 tsconfig `paths`。pnpm 为每个自行声明 `@types/react` 的 workspace 包创建一个 `node_modules/@types/react` 链接;66 个 manifest 声明了它,Client 测试聚合程序因此看到 65 个位置。计数器测得 88 个 JSX 文件 × 2 个模块(`index.d.ts`、`jsx-runtime.d.ts`)× 65 个位置,共 11,457 次 `tryGetModuleNameFromPaths` 调用、5,623,782 次条目扫描,合计 32.6 s。按可见位置数 N 截断的剂量实验在五个截断级别(N 从 1 到 65,Check 从 26.92 s 到 58.40 s)上拟合出 Check ≈ 26.9 + 0.494·N s。 +- **Host tsdown 阶段中的 Typert。** Host tsdown 阶段约 21.6–22.6 s。Typert 在第一个 `writeBundle` 中同步发现并生成产物,CPU profile 显示它阻塞 tsdown 主线程约 12.4 s,期间所有包构建都在等待。发现阶段占 8.06 s:它解析两个 face 聚合配置所引用的 362 个包 tsconfig(约 3.8 s),每次解析 Host 聚合配置都展开 2,064 条字面 `include`(1.6 s,分析器从不读取这些文件名),并对 322 个注册项做公开导出表面遍历(2.7–3.2 s);最终只有 28 个声明 Typert 导出的包产生 Host 产物。 +- **Client tsdown 阶段的 manifest 查找。** Client tsdown 阶段约 11.7 s,主线程饱和(空闲仅 7.1%),其中 34.5%(3.95 s)在 `workspaceManifest`:每个包配置按包名 `globSync('packages/*/*/package.json')` 并逐个解析 manifest,直到名字匹配。模块级缓存无效,因为 tsdown 的 native 配置加载器通过 `import-without-cache` 为每个包配置创建新的模块实例;每次 glob 耗时 40–125 ms,且发生在 `tsdown:deps` 的 `neverBundle`/`alwaysBundle` 回调中,阻塞全部构建。 + +#5849 已合并 Host fixture 类型收窄与 Typert memoization;本 Note 的基线是其后的 master。 + +## 决策 + +以下八项提速不改变 `lib/`、`apps/web/dist` 的产物字节,也不改变仓库构建报告的类型诊断集合。唯一的诊断归属变化来自第 4 项:Typert 不校验它不分析的包 tsconfig,这些错误只由 `tsc -b` 报告。命令拆分与并发调度由[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md)记录。 + +| # | 决策 | 受影响阶段 | 实测中位数 | 产物与诊断 | 有意的行为差异 | +|---|---|---|---|---|---| +| 1 | React 类型包只由根 manifest 声明 | Client 类型检查 | 测试聚合 Check 59.1 → 26.3 s | 程序文件数与诊断不变 | 包 manifest 不列出 React 类型包 | +| 2 | Typert 先按 manifest 选择包 | Host tsdown | 22.62 → 19.94 s | 逐字节一致 | 无 | +| 3 | Typert 不展开聚合配置的 `include` | Host tsdown | 19.94 → 16.99 s | 逐字节一致;注册清单一致 | 无 | +| 4 | Typert 惰性解析包 tsconfig | Host tsdown | 16.99 → 16.25 s(噪声内);Typert 发现 5.9–7.1 → 0.36–0.40 s | 逐字节一致;注册清单一致 | 未分析包的 tsconfig 错误只由 `tsc -b` 报告 | +| 5 | Typert 工作区根目录同时要求 `packages/` | Host tsdown | 噪声内 | 逐字节一致 | `package` 模式下双 face 包获得产物 | +| 6 | Typert 在 Worker Thread 中生成 | Host tsdown | 15.39 → 10.25 s | 逐字节一致;失败仍使 tsdown 失败 | Host tsdown 最大 RSS 约 +0.6 GiB | +| 7 | Typert `transform` 钩子按模块 id 过滤 | Host tsdown | 噪声内;钩子调用 1,566 → 195 | 逐字节一致 | 无 | +| 8 | Client tsdown 预设按包目录读取 manifest | Client tsdown | 12.78 → 8.73 s | 逐字节一致;external 判定一致 | 预设第一个参数是包目录 | + +第 5–7 项改变了发布的 `./tsdown` 导出 `typertPlugin()`(`transform` 为 `{ filter, handler }`、`writeBundle` 为异步、工作区根目录必须包含 `packages/`);rolldown 与 tsdown 的使用方接受两种钩子形式,分析器本来就要求 `/packages`,因此此前可用的外部工作区都不会失效。 + +### 测量条件 + +所有数字取自同一台共享 Linux 主机(256 vCPU、4 TB 内存,运行期间 load average 23–111),Node v24.18.0、TypeScript 6.0.3、tsdown 0.22.2(rolldown 1.1.1)。 + +- **Host tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host`,每次运行前删除全部 `lib/typert.*`;各变体交替顺序运行,每组 3–4 个样本。第 2–4 项取自第一轮测量(起点为基线),第 5–7 项取自第二轮测量(起点为第 2–4 项合入后的代码)。产物比较覆盖 `packages/*/*/lib`、`apps/*/lib`、`vendor/*/lib` 顶层的 743 个文件(含 140 个 Typert 产物)的 SHA-256。相同代码的两组中位数相差 0–0.11 s,组内样本跨度最大 2.5 s。 +- **Client 测试聚合:** references 预先构建后运行 `tsc -p tsconfig.client.json --extendedDiagnostics`,使用全新的 tsBuildInfo 文件;相同代码 6 个样本的 Check 跨度为 3.8 s(约 6%)。 +- **Client tsdown:** 已构建的树上运行 `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client`。第 8 项的原型 3 轮轮换顺序,基线跨度 1.56 s;最终实现与原实现交替运行,各 3 个样本。产物比较覆盖 `{packages,apps,vendor,native}/**/lib/**`(排除 `*.tsbuildinfo`)的全部文件。 + +### 1. React 类型包只由根 manifest 声明 + +- **目的:** 把 Client 测试聚合程序看到的 `@types/react` 位置数 N 从 65 降到 1,消除随 N 线性增长的 module specifier 计算。 +- **改动:** 根 `package.json` 的 `devDependencies` 声明 `@types/react` `~18.3.1` 与 `@types/react-dom` `~18.3.0`,沿用各包原先统一的版本范围;`apps/*`、`packages/*/*` 与 `vendor/*` 的 manifest 在任何依赖区段都不声明它们。TypeScript 从任一包向上查找时都解析到根 `node_modules/@types/react`。runtime `react` 与 `react-dom` 仍按 [Client 依赖声明规则](../../../../packages/client/AGENTS.md#dependency-declaration)由各包声明;只通过 `import type` 引用 React 的 `packages/client/ui-slots` 在 `devDependencies` 中声明 `react`。pnpm `dedupeDirectDeps` 保持关闭。lockfile 的变化集中在 importers 区段(另有一条 integrity 相同的 `papaparse` 条目重排)。 +- **防回退:** [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 在 `hygiene` 与 CI 中要求根 manifest 的 `devDependencies` 声明这两个包,并拒绝 `apps/*`、`packages/*/*`、`vendor/*` 中任一 manifest 在 `dependencies`、`devDependencies`、`optionalDependencies` 或 `peerDependencies` 中声明它们;诊断指明要删除的条目,并链接 Client 依赖声明规则第 8 条。规则只匹配这两个精确包名,其他 `@types/*` 包仍按 DefinitelyTyped 类型提供方处理。依赖区段推导不把这两个包视为 `react`/`react-dom` 的类型提供方:包缺少 `react` 声明时,验证器报告 `react` 缺失,`--fix` 拒绝修复并要求先声明版本范围;`--fix` 也不写回或删除这两个包。[`verify-package-dependencies.spec.ts`](../../../../scripts/verify-package-dependencies.spec.ts) 覆盖各依赖区段的拒绝、根 manifest 缺失声明、被接受的布局,以及类型提供方排除。 +- **性能:** + + | 测量项 | 各包自声明(N = 65) | 根 manifest 声明(N = 1) | + |---|---|---| + | Client 测试聚合 Check | 59.08 s(3 个样本的中位数) | 26.28 s(5 个样本的中位数),−56% | + | Client 测试聚合 wall | 68–71 s | 33.6–34.9 s | + | Client 测试聚合 user CPU | 79–83 s | 44.5–45.3 s | + | 76 个 client-only 包项目的 Check 总和 | 34.6 s(2 个样本的中位数) | 26.5 s(2 个样本的中位数),−23% | + | 76 个 client-only 包项目的 wall | 55.7 s(2 个样本的中位数) | 44.5 s(2 个样本的中位数) | + +- **无损依据:** 66 个包级链接与根链接指向同一个 realpath(`.pnpm/@types+react@18.3.31`),因此程序读取的声明内容相同;程序文件数保持 2,613,`tsc -b --force tsconfig.client.json` 以 0 退出且无诊断。`devDependencies` 不随已发布的包安装,[发布依赖面](2026-08-26-published-dependency-faces.zh.md)中独立 TypeScript 消费者自行安装所引用类型包的结论不变;runtime 解析不读取 `@types/*`。 +- **取舍:** 包 manifest 不列出自己的 React 类型依赖,所有 workspace 包共用同一个 React 类型版本;某个包需要不同版本时,需要新的 Agent Note 推翻本项。检查只覆盖 `apps/*`、`packages/*/*` 与 `vendor/*`;`website`、`benchmarks`、`native/system` 与 `python/sdk-runtime` 等其他 pnpm 成员不在范围内,目前它们都不声明 React 类型包。 + +### 2. Typert 先按 manifest 选择包 + +- **目的:** workspace 模式只为声明 Typert 导出的包生成产物,避免对其余候选包做表面遍历。 +- **改动:** `WorkspaceAnalyzer.discoverPackages(includeManifest?)` 与 `WorkspaceTypertGenerator.discover(faces?, includeManifest?)` 接受可选的 `package.json` 谓词,在表面遍历之前应用;被拒绝的包既不读取源码,也不解析项目配置。`WorkspaceTypertGenerator.generateContributors(faces?)` 以 `hasTypertExport`(`exports` 含 `./typert`、`./client/typert` 或 `./remote`)为谓词。不传谓词的调用方(Cordis 目录、不指定包的 `generate()`)行为不变。 +- **性能:** Host tsdown 22.62 s → 19.94 s(各 3 个样本的中位数)。 +- **无损依据:** 谓词就是原先在发现之后应用的同一过滤条件,被它拒绝的包本来就不产生产物;743 个产物文件逐字节一致(Typert generator 自身的 `lib/index.js` 除外,其源码即本项改动)。 + +### 3. Typert 不展开聚合配置的 `include` + +- **目的:** 分析器只读取聚合配置的编译选项与 project references,展开 `include` 只为填充从不使用的 `fileNames`。 +- **改动:** `WorkspaceCaches.aggregate(path)` 返回并缓存 `AggregateConfig { path, options, projectReferences }`。解析自身声明 `references` 的聚合配置时,使用 `readDirectory` 返回空列表的 `ParseConfigHost`;不声明 `references` 的聚合配置照常展开。 +- **性能:** Host tsdown 19.94 s → 16.99 s(各 3 个样本的中位数)。 +- **无损依据:** TypeScript 只对既无 `files` 也无 `references` 的配置报告 TS18003(无输入),因此声明 `references` 的聚合配置在不展开时得到相同的诊断、选项与 references。强制读取每个惰性配置后导出的注册清单(408 个注册项,含 `fileNames` 与选项,以及聚合配置的选项和 references)与基线逐字节一致;产物逐字节一致。测试 `reads aggregate options and references without expanding include patterns` 与 `reports an aggregate without references or inputs` 固定这两种配置的行为。 + +### 4. Typert 惰性解析包 tsconfig + +- **目的:** 只解析被选中分析的包的 tsconfig,而不是聚合配置引用的全部包。 +- **改动:** `PackageRegistration.config` 是 `caches.config(configPath)` 的 getter,首次读取时解析。注册清单的顺序、face、包名、根目录与导出子路径不变。 +- **性能:** Host tsdown 16.99 s → 16.25 s(各 3 个样本的中位数),在 2.5 s 的组内跨度以内。第 2–4 项合计在隔离基准中把 Typert 发现从 5.9–7.1 s 降到 0.36–0.40 s,tsconfig 解析从 362 次降到 28 次,表面遍历从 322 次降到 28 次,Typert 总耗时从 9.7–11.8 s 降到 4.4–4.5 s。 +- **无损依据:** 注册清单比较与第 3 项相同(逐字节一致),产物逐字节一致。测试 `reads only the project configs of manifests selected for discovery` 固定只解析所选包的配置。 +- **取舍(非无损):** Typert 不解析它不分析的包的 tsconfig,因此这类 tsconfig 的语法或配置错误不会使 Typert 失败。同一构建中,先于 Host bundle 运行的 Host 包编译 `tsc -b` 与 Client 包编译 `tsc -b` 解析每个被引用的 tsconfig,并拒绝其中的错误;`PackageRegistration.config` 的 JSDoc 与 [Typert generator README](../../../../packages/typert/generator/README.zh.md) 写明这一归属。单独调用 Typert 而不运行 `tsc -b` 的使用方不会得到这些诊断。 + +### 5. Typert 工作区根目录同时要求 `packages/` + +- **目的:** 修正工作区根目录的判定。24 个双 face 包带有自己的 `tsconfig.host.json` 叶子配置;只按 `tsconfig.host.json` 向上查找时,每个这样的包都被当作根目录,workspace 模式每次构建为它们各做一次空发现,`package` 模式下双 face 包得不到产物且没有报错。 +- **改动:** 工作区根目录是打包输出最近的、同时包含 `tsconfig.host.json` 与 `packages/` 的祖先目录。 +- **性能:** Host tsdown 14.82 s → 15.39 s(各 3 个样本的中位数),在噪声内。 +- **无损依据:** 仓库根 [`tsdown.config.ts`](../../../../tsdown.config.ts) 只使用 workspace 模式,743 个产物文件逐字节一致。测试 `resolves the workspace root above a dual-face package leaf` 固定该判定。 +- **行为差异:** `package` 模式的使用方构建双 face 包时得到该包的 Typert 产物;仓库构建不使用 `package` 模式。 + +### 6. Typert 在 Worker Thread 中生成 + +- **目的:** 让 Typert 分析与 tsdown 主线程上的打包重叠,而不是在第一个 `writeBundle` 中阻塞全部包构建。 +- **改动:** [`src/worker.ts`](../../../../packages/typert/generator/src/worker.ts) 是 Worker Thread 入口,运行 `generateContributors(faces)` 并回传一条 `artifacts` 或 `failure` 消息。workspace 模式下,本次构建的第一个 `buildStart` 以 `options.cwd` 定位工作区根目录并启动生成;`writeBundle` 是异步的,第一个完成的打包认领写入并等待结果,之后的打包立即返回。分析错误、worker 崩溃或提前退出、格式错误或无法反序列化的响应、worker 无法终止,都以指明工作区与原因的 `TypertWorkerError` 使认领写入的打包失败,从而使整个 tsdown 阶段失败;结果敲定前 worker 已终止,未被认领的结果不会成为未处理的 rejection。构建产物从 `./worker.js` 启动 worker;源码运行(测试)通过 `tsx/esm/api` 加载 `worker.ts`。`package` 模式仍在主线程中生成。 +- **性能:** Host tsdown 15.39 s → 10.25 s(各 3 个样本的中位数)。最终配置(第 2–7 项)的 profile:主线程 10.1 s,第一个 `buildStart` 出现在 4.18 s(tsdown 配置解析),主线程上的 `writeBundle` 只占 0.37 s;worker 5.0 s(模块加载约 0.4 s、发现 0.28 s、生成 3.88 s,其中 `createProgram` 2.21 s),约在 9.3 s 结束。Host tsdown 的 wall 约为 max(打包, 4.2 s + Typert)。 +- **无损依据:** 第二轮的 54 次运行全部以 0 退出,每次的 743 个产物文件均与基线一致(generator 自身的 `lib/index.js` 除外)。测试 `writes the artifacts in-process generation produces` 比较 worker 与进程内生成的产物;在临时工作区中把一个包的 `files` 改坏后,tsdown 在 2.4 s 内以 1 退出,报告 `TypertWorkerError` 及 worker 内的 `TypertAnalysisError`,不写入任何产物。各失败路径由 `typertPlugin workspace mode` 测试组覆盖。 +- **取舍:** worker 堆约 300–420 MiB;最终配置的 Host tsdown 最大 RSS 中位数为 6,296–6,408 MiB,基线为 5,676 MiB。共享主机高负载时 worker 可能落在关键路径上:一次样本为 21.91 s,拥有产物的包构建等待 worker 约 14 s,其后 10 个样本未复现;load average 约 100 时两次运行为 17.86 s 与 14.3 s。worker 在 1,024 MiB 堆上限下也能完成,因此不是 GC 压力;归因于主机争用尚未证实。 + +### 7. Typert `transform` 钩子按模块 id 过滤 + +- **目的:** 避免每个模块都从 rolldown 进入 JavaScript `transform` 钩子,只为被扩展名检查拒绝。 +- **改动:** `transform` 声明 `filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ }`,处理函数仍以同一正则检查 id。 +- **性能:** 每次 Host 构建的钩子调用从 1,566 次降到 195 次(195 个 TypeScript 模块,其中 4 个含装饰器)。wall 中位数为 11.07 s(3 个样本)与 9.98 s(4 个样本),第 6 项为 10.25 s,相同代码的对照组为 11.07 s 与 9.87 s,差异在噪声内。 +- **无损依据:** 该正则只含 ASCII 构造,在 rolldown 的 Rust 正则与 JavaScript 中匹配相同的 id 集合;处理函数应用同一检查,因此不读取过滤器的宿主行为相同。产物逐字节一致;测试 `filters transform calls to TypeScript module ids` 固定过滤器。 + +### 8. Client tsdown 预设按包目录读取 manifest + +- **目的:** 每个包配置只读取自己的 `package.json`,消除按包名遍历全部 manifest 的 glob。 +- **改动:** [`packages/client/tsdown.client.ts`](../../../../packages/client/tsdown.client.ts) 的 `clientBundle`、`staticLinked` 与 `clientLibrary` 的第一个参数是包目录(通常为配置文件的 `import.meta.dirname`),预设读取一次 `/package.json`,以其中的 `name` 作为插件 id 与诊断名称;该 manifest 缺失、不可读或缺少 `name` 时,配置加载以指明路径与原因的错误失败。82 个包配置调用这些预设。预设不保留模块级 manifest 缓存;Node 半边的生产依赖 external 列表在首次 resolve 时由该 manifest 计算,并缓存在该配置的闭包中。 +- **性能:** Client tsdown 从 12.78 s 降到 8.73 s(各 3 个样本的中位数),−4.05 s(−31.7%),两组样本区间不重叠;user CPU 从 26.1 s 降到 25.2 s,sys 从 5.9 s 降到 4.9 s,因此收益主要来自解除主线程阻塞,而不是减少 CPU 总量。原型(一次 glob 建立按包名索引)为 11.72 s → 7.46 s(−36%)。Host tsdown 中只有 5 个 `hostPhase` 包经过该查找,一对样本为 11.92 s → 10.71 s,置信度低。 +- **无损依据:** 11 次运行的 11,566 个 lib 文件逐字节一致。82 个调用处改为传入包目录后,没有一个解析出的包名与原先手写的包名不同。进程内比较 82 个配置 × 3 个 face × 1,600 个 specifier 的 external 判定,结果与原实现完全一致;负对照(多加一个 external)使 75 个判定不同。`client-bundle-purity.spec.ts` 覆盖这些 manifest 错误。 +- **行为差异:** 预设的调用约定从包名改为包目录。 + +## 考虑过的替代方案 + +- **开启 pnpm `dedupeDirectDeps`(第 1 项):** 作用于所有直接依赖,改变每个 workspace 包的链接布局,副作用是全局的。 +- **给 TypeScript 打补丁(第 1 项):** 上游式补丁(预先计算候选扩展名并缓存规范化的 `paths` 模式)使 Check 降低 31.9 s,与提升相当;在已提升的树上不再带来收益(refs Check 25.76 s 对 26.47 s),且需随每次 TypeScript 升级维护。 +- **在 tsconfig `paths` 中为 `react` 加别名(第 1 项):** tsx、vitest 与 vite 也读取 `paths`,别名会改变它们对 runtime `react` 的解析。 +- **把 `paths` 收窄为各 face 的 reference closure(第 1 项):** 保留通配键的 328 条版本 Check 为 55.73 s 对 59.08 s(1 个样本),在噪声内;去掉通配键的版本产生 18 个诊断。 +- **拆分 Client 测试聚合程序(第 1 项):** 每个子程序都必须注入 242 个含模块增强的文件才能通过检查,可见位置数仍为 33–49;拆成 2、4、8 组时 Check 总和基本不变,user CPU 分别增加 9%、30%、57%,只降低 wall,且 4 组与 8 组各出现 1–2 个诊断。 +- **缓存解析结果并把发现队列改为 O(1)(Typert):** 第 2–4 项之后只剩 28 次表面遍历,约 0.18 s,低于噪声。 +- **在产物构建中把 Typert 移到独立构建步骤(Typert):** 第 6 项之后 worker 已与打包重叠,剩余收益不超过约 1 s。不需要 bundle 的类型检查运行只读源码的生成步骤([本地门禁提速](2026-10-12-local-gate-speedups.zh.md))。 +- **按代码内容过滤 `transform`(第 7 项):** rolldown 以 Rust 正则语义解释 `^` 与 `\s`,在孤立 CR、U+2028 与 BOM 上与 JavaScript 不同,可能漏掉含装饰器的模块。 +- **在 `globalThis` 上建立进程级 manifest 索引(第 8 项):** 原型收益与第 8 项相当,但它是没有 owner 的进程全局状态,违反[包规则](../../../../packages/AGENTS.md)中的共享状态归属要求。 +- **关闭 tsdown 的 gzip 报告(第 8 项):** `--no-report` 使 Client tsdown 降低 0.95 s,在 1.56 s 的基线跨度内。 +- **为 DSH 的 `resolveId`/`load` 钩子加 rolldown 过滤器(第 8 项):** 降低 1.16 s,在噪声内。 +- **按模块 id 缓存输入隔离检查(第 8 项):** 降低 0.93 s,在噪声内;叠加在 manifest 索引之上只再降低 0.18 s。 + +## 后果 + +Client 类型检查的 CPU 与 wall 时间、Host tsdown 与 Client tsdown 的 wall 时间都明显下降,且不改变 runtime 解析、发布安装语义或任何产物字节。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;串行各段合计在 master 上为 308.5 s,在本分支上为 254.9 s(见[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md))。Host tsdown 位于 `build` 与 `build:artifacts` 的关键路径上,Client tsdown 位于 `build:artifacts` 的关键路径上,Client 聚合检查是 `build` 关键路径的最后一步。 + +代价:所有 workspace 包共用一个 React 类型版本;Typert 不校验它不分析的包 tsconfig,这些错误由 `tsc -b` 负责;Host tsdown 的最大 RSS 约增加 0.6 GiB,共享主机高负载时 worker 可能成为 Host tsdown 的关键路径。 + +重新评估条件:迁移到 TypeScript 7 时重新测量第 1 项,若位置数不再影响 Check 时间,可以重新评估包级声明。tsdown 的配置加载本身每次 Client 构建仍占 1.8–2.3 s(每个配置一个无缓存的模块实例),需要上游改动。第一个 `buildStart` 在 Host 构建开始约 4.2 s 后才出现;在插件构造时以显式根目录启动 worker 可以进一步提前 Typert。 + +验证:Typert generator 的 `tsdown-plugin.spec.ts`、`tsdown-plugin-worker.spec.ts` 与 `type-model.spec.ts` 固定第 2–7 项的行为;generator 测试 209 个通过、28 个跳过,`tsc -b tsconfig.host.json` 以 0 退出。仓库覆盖率 gate 不包含 `packages/typert/*/src/**`,在临时覆盖率配置下 `tsdown-plugin.ts` 的 statements、branches、functions 与 lines 均为 100%。五个负对照(旧根目录规则、立即解析包配置、展开聚合配置、不认领写入、跳过 worker 终止)各自使守护它的测试失败;真实 tsdown 失败路径在 2.4 s 内以 1 退出并报告 `TypertWorkerError`。第 1 项:`verify-package-dependencies` 以 0 退出(74 个包),其 spec 47/47 通过,`verify-client-packages` 以 0 退出,`tsc -b tsconfig.client.json` 无诊断,Client 聚合 Check 为 26.48 s。第 8 项:72 个相关 spec 全部通过。 diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml new file mode 100644 index 0000000000..80284ccdd5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml @@ -0,0 +1,49 @@ +# Bilingual-pair consistency record for 2026-10-12-local-gate-speedups.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-12-local-gate-speedups.md +/agent-note-local-gate-hook-and-test-speed-ups: + en: d8edb2c26730e9af + zh: fb69bf2e7b602d4c +/agent-note-local-gate-hook-and-test-speed-ups/problem: + en: 1e2163ec378c44ff + zh: d93cb5970896fccb +/agent-note-local-gate-hook-and-test-speed-ups/decision: + en: 9984b4ad646bfaf2 + zh: 753287ae67e29605 +/agent-note-local-gate-hook-and-test-speed-ups/decision/measurement-conditions: + en: 7430353afba96352 + zh: 6d2a09619a67ecba +/agent-note-local-gate-hook-and-test-speed-ups/decision/1-type-checks-generate-typert-declarations-without-bundling: + en: 4dc48e24a3323b7d + zh: 7c88325160a41d12 +/agent-note-local-gate-hook-and-test-speed-ups/decision/2-the-pre-commit-hook-does-not-regenerate-third-party-notices: + en: e2e344f184621172 + zh: 97d8785aaf9105bf +/agent-note-local-gate-hook-and-test-speed-ups/decision/3-local-default-worker-budget: + en: e9649c40d67e26e0 + zh: c99f22a41c4fc4af +/agent-note-local-gate-hook-and-test-speed-ups/decision/4-repository-reference-candidates-resolve-against-one-object-listing: + en: 2c945250337c88cf + zh: d4bfa34cc858782b +/agent-note-local-gate-hook-and-test-speed-ups/decision/5-documentation-gates-parse-each-document-once: + en: c22d38399a2bb87f + zh: 9ae97e5a6e3fbf48 +/agent-note-local-gate-hook-and-test-speed-ups/decision/6-repository-file-walks-list-each-directory-once: + en: 2f0d35b0a688c938 + zh: 7184fd260c066a01 +/agent-note-local-gate-hook-and-test-speed-ups/decision/7-cli-help-reads-run-concurrently-doc-graph-probes-are-memoized: + en: 965503ba8a31c039 + zh: fe02a394a677d018 +/agent-note-local-gate-hook-and-test-speed-ups/decision/8-type-model-spec-ts-runs-against-an-in-memory-workspace: + en: d519ec362903aa2f + zh: f49ee6547cb954f6 +/agent-note-local-gate-hook-and-test-speed-ups/decision/repository-wide-comparison: + en: 7c9388dc859e0f93 + zh: 5df1fc58eb7a46e8 +/agent-note-local-gate-hook-and-test-speed-ups/alternatives-considered: + en: 763d94734b8967cf + zh: abe980d486e1d7da +/agent-note-local-gate-hook-and-test-speed-ups/consequences: + en: ca00b04567d09408 + zh: 97011043c60f4bc9 diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md new file mode 100644 index 0000000000..22144834ef --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md @@ -0,0 +1,158 @@ +# Agent Note: Local gate, hook, and test speed-ups + +Status: implemented + +English | [中文](2026-10-12-local-gate-speedups.zh.md) + +## Problem + +On the baseline, master at the merge of #6013, local runs of the Git hooks, `pnpm run test`, `test:docs`, and `doc-sync` spend most of their wall time on work that their results do not depend on: + +- **Typert declarations from the bundler.** `typecheck`, the command the pre-push hook runs, executes `build:lib:host` (Host `tsc -b tsconfig.host.json`, the Host tsdown pass, and the Desktop bundle) before `tsc -b tsconfig.client.json`. The prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and the CI `typert-contracts` gate runs Host package compilation and then the Host tsdown pass (`compile-referenced-projects.ts host-libraries`). Type checks read only package declarations and Typert declarations, yet `typecheck` with nothing to recheck takes 34.1/35.0 s and the prerequisite takes 52.2/29.6 s. +- **Third-party notices on every commit.** The pre-commit hook regenerates `THIRD_PARTY_NOTICES.md` whenever a staged file is a generator input. To find browser-side third-party imports, the generator test-bundles 75 Client packages and runs one Vite build, so every package source file is an input; on a commit that stages one file under a package `src/` subdirectory, the hooks take 16.5/17.8 s, of which the notices job takes 12.4/13.4 s. +- **Host CPUs as the local worker count.** `os.availableParallelism()` reports the host's CPUs (256 on the measurement host) rather than the container's CPU quota (64). With that default, `pnpm run test` starts 255 Vitest forks, peaks at 55.3 GiB RSS, and fails 12 additional files (14 timeouts) intermittently, while `doc-sync`, `test:docs`, `hygiene`, and `check:all` run at most 4 gates concurrently. +- **One Git prefix lookup per candidate.** `verify-repository-references` resolves each of about 26,000 abbreviated hexadecimal candidates with a Git prefix lookup through `git cat-file --batch-check`; the lookups take 135.7 s of the gate's 166.8 s. +- **Repeated Markdown parsing.** markdown links parses each anchor target twice more and probes target existence without a cache; translation pairing globs four times and parses each document again after link normalization; the package README model-experience and limitations checks each parse every README three times; type equivalence parses all 1,835 documents; Session format references parses each pair four times. +- **Repeated directory reads.** `uniqueRepoFiles`, the walker behind markdown wrap, doc refs, and package paths, reads each directory once per pattern and `**` level and resolves every matched file with `realpath`. +- **Serial help launches and repeated compiler probes.** `gen-cli-help` launches its 12 top-level source-mode `dsh … --help` processes one after another, each waiting mostly on profile boot I/O (49.9/53.9 s); `gen-doc-graphs` builds several compiler hosts and programs that repeat the same `ts.sys` file-system probes (48.7/44.7 s). +- **A test file bound by file-system probes.** Each of the 61 cases of the Typert generator's `type-model.spec.ts` copies its fixture to disk and runs a full analysis whose four TypeScript programs each load the DOM library, `@types/node`, and zod v4, about 22,000 stat calls per analysis. The file takes 316.3/329.1 s and is the long tail of `pnpm run test`. + +## Decision + +The eight decisions below shorten those runs. Each item states the evidence that its outputs and diagnostics stay the same, or the intended difference it accepts. + +| # | Decision | Affected commands | Measured wall | Outputs and diagnostics | Intended behavior difference | +|---|---|---|---|---|---| +| 1 | Type checks generate Typert declarations without bundling | `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, CI `typert-contracts` | `typecheck` 34.1/35.0 → 21.9/20.0 s; prerequisite 52.2/29.6 → 12.5/11.3 s | 140 Typert files byte-identical; the same programs are checked | No runnable bundle and no client build record | +| 2 | The pre-commit hook does not regenerate third-party notices | `pre-commit` | 16.5/17.8 → 4.3/4.5 s | Freshness asserted in the test lane | Stale notices fail in the test lane, not at commit | +| 3 | Local default worker budget | run-gates aggregates, `build`, `typecheck`, `pnpm run test`, Oxlint, publint | `test` at 255 workers: 243.7 s, 55.3 GiB; `test:docs` 74.7 → 50.9 s | Same checks | Local defaults ignore CPUs above 12 or 48 | +| 4 | Repository-reference candidates resolve against one object listing | `verify-repository-references` | 166.8 → 15.7 s | 0 classification differences over 27,050 candidates | None | +| 5 | Documentation gates parse each document once | markdown links, translation pairing, README model experience and limitations, type equivalence, Session format references | markdown links 53.4/51.6 → 25.9/25.1 s; type equivalence 23.0/22.5 → 6.0/5.5 s | Byte-identical gate output | None | +| 6 | Repository file walks list each directory once | markdown wrap, doc refs, package paths | doc refs 8.8/7.0 → 3.2/3.4 s | Identical file lists | doc refs does not read nested `lib` directories | +| 7 | CLI help reads run concurrently; doc-graph probes are memoized | `verify-cli-help`, `verify-doc-graphs` | 49.9/53.9 → 6.9/7.2 s; 48.7/44.7 → 32.7/34.2 s | Byte-identical pages | CLI help uses about 3.6 GB at peak | +| 8 | `type-model.spec.ts` runs against an in-memory workspace | `pnpm run test` | 316.3/329.1 → 14.65/15.04 s | Same 61 cases and assertions; one snapshot line differs | Stubs replace zod, `@types/node`, and the DOM library outside the Emitter case | + +### Measurement conditions + +All numbers are wall times from one shared Linux container with a 64-CPU quota on a host that reports 256 CPUs, at a one-minute load average of 20–150, so samples carry noise. The working tree lives on a network filesystem, where each file-system probe costs more than on a local disk. `a/b` lists two samples, and `x → y` compares the baseline with this implementation. + +### 1. Type checks generate Typert declarations without bundling + +- **Purpose:** Give `typecheck` and the prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and the CI `typert-contracts` gate the declarations they read, package declarations from `tsc` and Typert declarations, without the Host tsdown pass or the Desktop bundle. +- **Change:** `typecheck` is `tsx scripts/build.ts --scope typecheck`, which runs the steps below through the run-gates scheduler ([`scripts/build.ts`](../../../../scripts/build.ts)). The scope builds no native addon, tsdown bundle, Desktop bundle, or Web output, writes no client build record, and leaves runnable `lib/` output as the last `build:artifacts` left it; run `build:artifacts` before launching or testing built output. The prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` is `compile-referenced-projects.ts host-contracts`, which runs host-src beside host-typert ([`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts)). `build`, `build:artifacts`, benchmark builds, and the PR rehearsal in `release-vendor.yml` run Typert inside the Host tsdown pass. + +| Step | Command | `needs` | +|---|---|---| +| host-src | `node --max-old-space-size=4096 -b ` | — | +| host-typert | `node --import tsx/esm scripts/generate-typert.ts` | — | +| client-src | ` -b ` | host-src, host-typert | +| host-check | `node --max-old-space-size=4096 -b tsconfig.host.json` | host-src | +| client-check | ` -b tsconfig.client.json` | client-src | + +- **Shared generation:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) calls `WorkspaceTypertGenerator.generateContributors()` for `WORKSPACE_TYPERT_FACES` ([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts)), the faces the Host tsdown plugin also generates, and writes the result with `writeWorkspaceArtifacts()` from [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts), the writer the plugin uses. Like the plugin, it skips TypeScript diagnostics, which Host package compilation reports; a Typert analysis error fails host-typert as it fails the Host bundle in `build`. host-typert runs under Node with tsx's ESM hook rather than through `pnpm exec`, whose startup would lengthen the path to Client compilation. +- **Aggregate checks in build mode:** host-check and client-check start after their own face's package compilation, so build mode finds every reference up to date, checks only the aggregate root program, and skips a root program whose tsbuildinfo is current. +- **Source-only analysis:** host-typert can run beside host-src because its Host program reads no workspace build output. `tsconfig.base.json` maps `@deepseek-ai/dsh-client-file-upload/types` to source; without that alias the specifier resolves through package `exports` to `lib/types/types.d.ts`. With it, the Host program loads 1,289 files, none of them under a workspace package's `lib/`. `generate-typert.ts` enables `rejectBuildOutputs`: [`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) fails analysis with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory, or cannot resolve an import of a workspace package, as on a tree whose packages are not yet compiled, naming each such import specifier and the `paths` entry that maps it to source. Either way a missing alias fails generation whether or not host-src has run. The tsdown plugin leaves the option off because in `build` Typert always runs after Host package compilation. The [Typert generator README](../../../../packages/typert/generator/README.md) documents the writers and the option. +- **Why it is lossless:** No workspace tsconfig sets `allowJs`, every `exports` `types` condition points into `lib/types/` or at `lib/typert.*`, and every tsdown config sets `dts: false`, so no type check reads tsdown output. The 140 Typert files (28 packages × 5 outputs) are byte-identical across the baseline tsdown pass, the current tsdown pass, `generate-typert.ts` after every Typert output is deleted, and `generate-typert.ts` with all 344 `lib/types` directories hidden; with one Remote method temporarily added to `packages/goal/goal`, the tsdown pass and `generate-typert.ts` also write identical files. `typecheck` checks every package project of both faces and both aggregate root programs, the programs that `build` checks. +- **Performance:** `typecheck` with nothing to recheck takes 34.1/35.0 → 21.9/20.0 s. When both aggregate root programs must be rechecked, it takes 83.5 → 76.0/72.1 s, and its critical path is the Host aggregate check at about 66–70 s. The prerequisite of `lint` and `doc-typecheck` takes 52.2/29.6 → 12.5/11.3 s. +- **Trade-off:** `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` write Typert declarations but no bundle, so runnable `lib/` JavaScript can lag behind those declarations until the next `build:artifacts`. +- **Relation to the build:** [The lossless build speed-ups note](2026-10-11-lossless-build-speedups.md) rejects moving Typert out of the Host tsdown pass for builds, where its Worker Thread already overlaps bundling and a separate step saves at most about 1 s. Type-check commands run no bundler, so generating without one removes the whole Host tsdown pass and the Desktop bundle from their critical path. [The build split note](2026-10-11-build-artifacts-check-split.md) lists every caller and its command. +- **Known gap:** Incremental `tsc -b` does not record `lib/typert.remote-client.d.ts`, which Client sources reach through package `exports`, as an input of the consuming project; when a Remote signature changes and no Client source does, an incremental check does not recheck the consumers. The gap is independent of which pass writes the declarations; Issue #6022 tracks it. + +### 2. The pre-commit hook does not regenerate third-party notices + +- **Change:** The `pre-commit` hook in [`lefthook.yml`](../../../../lefthook.yml) has no third-party notices job. [`gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) asserts the committed bytes of `THIRD_PARTY_NOTICES.md` in the test lane, including the required `node 24 / coverage` lane, and names `pnpm run gen-third-party-notices` when they are stale. [The generated third-party notices note](2026-07-30-generated-third-party-notices.md) owns this freshness decision. +- **Performance:** A commit that stages one file under a package `src/` subdirectory spends 4.3/4.5 s in the hooks instead of 16.5/17.8 s, of which the notices job took 12.4/13.4 s. +- **Trade-off:** A commit that leaves the notices stale fails in the test lane after push rather than being corrected at commit time. + +### 3. Local default worker budget + +- **Purpose:** Size default parallelism for a shared workstation or container, where Node reports host CPUs rather than the CPU quota. +- **Change:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) owns the default. With `CI=true` it is the CPU count Node reports. Otherwise it is capped at 12 (`LOCAL_PARALLELISM_CAP`) for the run-gates scheduler, `build`, `typecheck`, Oxlint, and publint, and at 48 (`LOCAL_TEST_PARALLELISM_CAP`) for Vitest forks and the local coverage budget. `doc-sync`, `test:docs`, `hygiene`, and `check:all`, whose gates each build a full `ts.Program`, run at most 8 gates concurrently, in CI as well. Explicit `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` take precedence; in CI, Vitest and Oxlint keep their own defaults unless the run-gates CI environment sets these variables. The [development guide](../../../../docs/development.md) states the defaults for contributors. + +`pnpm run test` at four fork counts, with the disk-backed `type-model.spec.ts` that item 8 replaces, which sets the wall of the 32-worker run: + +| Vitest workers | Wall | Peak RSS | Additional intermittent failures | Every file except the slowest finished at | +|---|---|---|---|---| +| 255 (the reported CPU count) | 243.7 s | 55.3 GiB | 12 files, 14 timeouts | about 135–170 s | +| 32 | 310.4 s | 15.7 GiB | 4 files, 4 timeouts | 193 s | +| 24 | 337.2 s | 13.2 GiB | 5 files, 5 timeouts | 233 s | +| 12 | 410.5 s | 8.4 GiB | 2 files, 3 timeouts | 406 s | + +- **Oxlint:** Oxlint's own default is 64 threads, read from the cgroup quota; at 12 threads its wall stays about 87–90 s with about 15% less CPU, and at 8 threads it takes about 4 s longer. +- **Documentation aggregates:** `test:docs` takes 50.9 s at 8 concurrent gates against 74.7 s at 4. `doc-sync`, measured without item 4, takes 341.8, 295.0, and 253.2 s at 4, 8, and 12 concurrent gates, with peak RSS of 10.0, 13.5, and 16.3 GiB. +- **Trade-off:** A machine whose CPU quota exceeds the caps gets no more default parallelism than 12 or 48 and needs the override variables; a container whose quota is below a cap still oversubscribes, because Node reports host CPUs. At 8 concurrent gates `doc-sync` peaks at 13.5 GiB RSS against 10.0 GiB at 4; 12 concurrent gates finish 41.8 s sooner at 16.3 GiB. The CI documentation workflow also runs `doc-sync` at 8 concurrent gates. + +### 4. Repository-reference candidates resolve against one object listing + +- **Change:** [`verify-repository-references.ts`](../../../../scripts/verify-repository-references.ts) lists every object name once with `git cat-file --batch-all-objects --batch-check=%(objectname)`, about 1.2 million objects in 0.6 s, sorts the list, and binary-searches each lowercase candidate prefix in memory; one `--batch-check=%(objectname) %(objecttype)` call then reads the types of the matched objects. A candidate is a commit reference only when its prefix matches exactly one available object and that object is a commit, the rule Git's own prefix lookup applies; the listing covers unreachable and alternate objects, as Git's lookup does. +- **Performance:** 166.8 → 15.7 s. +- **Why it is lossless:** Git's per-candidate prefix lookup and the in-memory search classify all 27,050 real and synthetic candidates identically, including ambiguous prefixes, blob and tree prefixes, and random strings. +- **Trade-off:** The listing is about 49 MiB of Git output for 1.2 million objects, read under a 1 GiB output limit. + +### 5. Documentation gates parse each document once + +- **Change:** Each gate below parses a Markdown document once and passes the tree to every consumer in that run. + +| Gate | Single-parse mechanism | Wall | +|---|---|---| +| markdown links ([`verify-md-links.ts`](../../../../scripts/verify-md-links.ts)) | One per-run cache parses each file once for its destinations and anchors and probes each resolved target once | 53.4/51.6 → 25.9/25.1 s | +| translation pairing ([`verify-translation-pairing.ts`](../../../../scripts/verify-translation-pairing.ts)) | One glob covers every scope pattern; the record reuses the authored tree under the guard below | 51.0/47.1 → 30.3/29.3 s | +| package README model experience, package README limitations | Each check parses a README once and passes the tree to `markdownProseLines()` and `markdownHeadingLines()` | about 12 → 5.9 s each | +| type equivalence ([`verify-type-equiv.ts`](../../../../scripts/verify-type-equiv.ts)) | A text prefilter skips documents that cannot hold a source-equivalence fence; each manifest TypeScript source is parsed once | 23.0/22.5 → 6.0/5.5 s | +| Session format references | The persistence renderer passes the trees it parses to the pairing record | 10.2 → 5.3 s | + +- **Pairing reuse guard:** `normalizedTopLevelBlocks()` in [`translation-pairing-record.ts`](../../../../scripts/translation-pairing-record.ts) reuses the authored tree for normalized text only when every link-normalization edit replaces a complete, title-free destination that does not start a line, the authored text equals the node's `url`, and both the authored and the replacement text contain only `[A-Za-z0-9._/:#%-]`. Those characters cannot end a destination or start or end another construct, so the normalized text parses to the same nodes with shifted offsets. Any other edit falls back to a full parse of the normalized text. +- **Type-equivalence prefilter:** [`type-equiv-prefilter.ts`](../../../../scripts/type-equiv-prefilter.ts) keeps a document for parsing when it contains `type-equiv` or `public-api`, or when a line containing `` ``` `` or `~~~` also contains `&` or `\`. Parsing changes an info string only by decoding character references and backslash escapes, so `ts type-equiv` and `ts type\-equiv` keep their documents; [`type-equiv-prefilter.spec.ts`](../../../../scripts/type-equiv-prefilter.spec.ts) covers both forms. +- **Why it is lossless:** Every gate's output on the repository is byte-identical, and seeded faults are reported identically. For pairing, the top-level blocks of all 1,886 corpus documents and the records of all 943 pairs match those of a full reparse, as do randomized destinations. + +### 6. Repository file walks list each directory once + +- **Change:** `uniqueRepoFiles()` in [`scripts/repo-files.ts`](../../../../scripts/repo-files.ts) shares one directory-listing cache across all patterns of a call, so each directory is read at most once, and calls `realpath` only for matches reached through a symbolic link; a regular file reached through real directories canonicalizes to the repository's realpath joined with its relative path. Its optional `skipDirectory` predicate keeps the walker out of a directory whose every path the caller excludes; [`verify-doc-refs.ts`](../../../../scripts/verify-doc-refs.ts) skips every `lib` directory below the first path segment, whose files it excludes in any case. +- **Performance:** markdown wrap 25.8/25.0 → 21.3/21.2 s; doc refs 8.8/7.0 → 3.2/3.4 s; package paths 9.9/8.1 → 7.2/5.5 s. +- **Why it is lossless:** All three gates receive identical file lists, and a broken or cyclic symbolic link still fails through `realpath`. +- **Behavior difference:** doc refs does not list a skipped `lib` directory, so an unreadable one does not fail the gate with EACCES. + +### 7. CLI help reads run concurrently; doc-graph probes are memoized + +- **CLI help:** `prefetchHelp()` in [`scripts/gen-cli-help.ts`](../../../../scripts/gen-cli-help.ts) starts the 12 top-level `dsh … --help` reads (the launcher, `plugin`, and each profile's shorthand and `--profile` forms) within the default worker budget, each with its own temporary `DSH_HOME` and `DSH_AGENTS_HOME`, and the in-order traversal consumes them; nested reads share the same limit. A failed read reaches the traversal when it requests that invocation, and `close()` aborts queued and running reads and waits for every started child. The gate takes 49.9/53.9 → 6.9/7.2 s. +- **Doc graphs:** `withMemoizedFileSystemProbes()` in [`scripts/gen-doc-graphs.ts`](../../../../scripts/gen-doc-graphs.ts) memoizes `ts.sys` `fileExists`, `directoryExists`, `realpath`, `readDirectory`, and `getDirectories` while `renderDocs` runs, so every compiler host and config parse in one run shares one view of the file system. The workspace must not change during the run, and the original methods are restored before pages are written. The gate takes 48.7/44.7 → 32.7/34.2 s. +- **Why it is lossless:** Both generators produce byte-identical pages. +- **Trade-off:** The concurrent help processes use about 3.6 GB of memory combined at peak. + +### 8. `type-model.spec.ts` runs against an in-memory workspace + +- **Change:** [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) mocks `node:fs` with `memoryFileSystem()` and the `ts.sys` host from [`type-model-workspace.ts`](../../../../packages/typert/generator/tests/type-model-workspace.ts), so fixture copies, case edits, write-mode annotations, tsconfig expansion, and module resolution under the in-memory root never touch the disk. Each workspace mounts minimal `zod` and `@types/node` declaration stubs; only the Emitter case keeps real zod. The fixture's `tsconfig.base.json` sets `lib: ["ES2024"]`, which excludes the DOM library, and cases that read the unmodified fixture share one deep-frozen analysis. The generator source has no test-specific path. +- **Performance:** The file alone takes 316.3/329.1 → 14.65/15.04 s. +- **Why the behavior coverage stays:** The 61 case names and their assertions are the same. The snapshot differs in one line, the external zod symbol path, which names the stub (`:node_modules/zod/index.d.ts#ZodType`) rather than a pnpm store version. +- **Trade-off:** Outside the Emitter case, the spec does not exercise the analyzer against the real file system, the DOM library, or the real zod and `@types/node` declarations. The memory file system implements only the `node:fs` calls the analyzer makes (`existsSync`, `readFileSync`, `realpathSync`, and `writeFileSync`); another call on an in-memory path reaches the real file system, where the path does not exist. + +### Repository-wide comparison + +Both trees were measured in the same session, alternating baseline and implementation runs of each command after `pnpm run build:artifacts` (`pnpm run test` fails the same environment-dependent files on both trees, 42 on the baseline and a subset of 28 here): + +| Command | Baseline | This implementation | Change | Items | +|---|---|---|---|---| +| `pre-commit` hooks, one staged file under a package `src/` subdirectory | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 | +| `pnpm run typecheck` (pre-push), nothing to recheck | 29.7/23.3 s | 14.2/16.1 s | −43% | 1, 3 | +| `pnpm run lint` | 110.0 s | 106.7 s | −3% | 1 | +| `pnpm run doc-typecheck` | 45.2 s | 36.1 s | −20% | 1 | +| `pnpm run test:docs` | 102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 | +| `pnpm run doc-sync` | 186.8 s | 70.3 s | −62% | 3–7 | +| `pnpm run test` | 222.7 s (255 workers) | 148.1 s (48 workers) | −34% | 3, 8 | + +`lint` time is Oxlint itself; its prerequisite falls as item 1 states. CPU time falls with wall: `typecheck` user time 39.5 → 14.2 s, `test:docs` 212.6 → 126.3 s, `doc-sync` 628.2 → 503.2 s, and `test` 3,417 → 3,014 s. + +## Alternatives considered + +- **Cap every local default at 12, including Vitest forks (item 3):** `pnpm run test` takes 410.5 s at 12 workers against 243.7 s at 255, 68% longer. +- **Use half of the cgroup CPU quota as the local budget (item 3):** Reading the quota is a larger change and helps only inside containers. +- **Parse documents in parallel `worker_threads` (item 5):** It adds more complexity than parsing each document once, which removes the repeated work itself. +- **Regenerate third-party notices in the pre-commit hook (item 2):** Browser inputs make every package source file a generator input, so the hook runs the 16-second generator on nearly every commit while few commits change its output; [the generated third-party notices note](2026-07-30-generated-third-party-notices.md) owns the decision. + +## Consequences + +The pre-push `typecheck` with nothing to recheck takes about 14–22 s, the `lint` and `doc-typecheck` prerequisite about 12 s, and the hooks of a one-file commit about 4 s. `pnpm run test` starts at most 48 forks locally, and `test:docs` and `doc-sync` run up to 8 gates at once. Typert outputs, generated pages, file lists, and reference classifications are byte-identical to the baseline. + +Costs: type-check commands do not refresh runnable bundles; a stale notices file fails in the test lane after push; local defaults ignore CPUs above the caps and still oversubscribe a container whose quota is below them; 8 concurrent documentation gates raise the `doc-sync` RSS peak; CLI help uses about 3.6 GB at peak; doc graphs and markdown links assume the tree does not change during a run; `type-model.spec.ts` exercises stubs rather than real zod, `@types/node`, the DOM library, and disk I/O outside the Emitter case; and incremental `typecheck` inherits the Remote-declaration gap that Issue #6022 tracks. + +Verification: `generate-typert.spec.ts` pins byte-identical files against the workspace-mode plugin, rejection of a Host program that loads a compiled workspace declaration with the missing alias named, and each failure stage; `compile-referenced-projects.spec.ts` pins the `host-contracts` steps and their failure reporting; `build.spec.ts` pins the typecheck scope's steps; `parallelism.spec.ts`, `run-gates.spec.ts`, and `run-oxlint.spec.ts` pin the CI and local budgets and the overrides; `repo-files.spec.ts`, `markdown.spec.ts`, `translation-pairing.spec.ts`, `type-equiv-prefilter.spec.ts`, `verify-md-links.spec.ts`, `gen-cli-help.spec.ts`, and `gen-doc-graphs.spec.ts` pin single directory listing, parse reuse, the pairing guard, the prefilter, the prefetch order and failure path, and probe memoization with restoration. diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md new file mode 100644 index 0000000000..faf66b3153 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md @@ -0,0 +1,158 @@ +# Agent Note: 本地门禁、钩子与测试提速 + +Status: implemented + +[English](2026-10-12-local-gate-speedups.md) | 中文 + +## 问题 + +在基线(合并 #6013 时的 master)上,本地运行 Git 钩子、`pnpm run test`、`test:docs` 与 `doc-sync`(文档同步门禁)时,大部分 wall 时间花在结果并不依赖的工作上: + +- **经打包器获得 Typert 声明。** pre-push 钩子运行的 `typecheck` 先运行 `build:lib:host`(Host `tsc -b tsconfig.host.json`、Host tsdown 阶段与 Desktop bundle),再运行 `tsc -b tsconfig.client.json`。`lint`、`lint:fix`、`doc-typecheck` 与 CI `typert-contracts` 门禁的前置步骤先运行 Host 包编译,再运行 Host tsdown 阶段(`compile-referenced-projects.ts host-libraries`)。类型检查只读取包声明与 Typert 声明,但无需重查时 `typecheck` 仍需 34.1/35.0 s,前置步骤需 52.2/29.6 s。 +- **每次提交都生成第三方声明。** 只要暂存文件是生成器的输入,pre-commit 钩子就重新生成 `THIRD_PARTY_NOTICES.md`。为找出浏览器侧的第三方 import,生成器对 75 个 Client 包试打包并运行一次 Vite 构建,因此每个包源码文件都是它的输入;暂存一个位于包 `src/` 子目录下的文件的提交,钩子合计 16.5/17.8 s,其中第三方声明任务占 12.4/13.4 s。 +- **以宿主 CPU 数作为本地 worker 数。** `os.availableParallelism()` 报告的是宿主 CPU 数(测量主机上为 256),而不是容器的 CPU 配额(64)。按此默认值,`pnpm run test` 启动 255 个 Vitest fork,RSS 峰值 55.3 GiB,并偶发地多失败 12 个文件(14 次超时);同时 `doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多只并发 4 个门禁。 +- **每个候选一次 Git 前缀查找。** `verify-repository-references` 通过 `git cat-file --batch-check` 对约 26,000 个缩写十六进制候选逐个做 Git 前缀查找;这些查找占该门禁 166.8 s 中的 135.7 s。 +- **重复解析 Markdown。** markdown links 对每个锚点目标再多解析两次,且探测目标是否存在时不缓存;translation pairing 做四次 glob,并在链接归一化后再次解析每个文档;包 README 的 model experience 与 limitations 检查各自把每个 README 解析三次;type equivalence 解析全部 1,835 个文档;Session format references 把每个配对解析四次。 +- **重复读取目录。** markdown wrap、doc refs 与 package paths 背后的遍历器 `uniqueRepoFiles` 对每个 pattern 与每层 `**` 各读取一次每个目录,并对每个匹配文件调用 `realpath`。 +- **串行启动 help 与重复的编译器探测。** `gen-cli-help` 依次启动 12 个顶层源码模式的 `dsh … --help` 进程,每个进程大部分时间在等待 profile 启动的 I/O(49.9/53.9 s);`gen-doc-graphs` 构建多个编译器 host 与 program,重复执行同样的 `ts.sys` 文件系统探测(48.7/44.7 s)。 +- **受文件系统探测拖累的测试文件。** Typert generator 的 `type-model.spec.ts` 的 61 个用例各自把 fixture(测试前置数据)复制到磁盘并运行一次完整分析,分析中的四个 TypeScript program 各自加载 DOM lib、`@types/node` 与 zod v4,每次分析约 22,000 次 stat 调用。该文件耗时 316.3/329.1 s,是 `pnpm run test` 的长尾。 + +## 决策 + +以下八项决策缩短上述运行。每一项都给出其产物与诊断保持不变的依据,或它接受的有意差异。 + +| # | 决策 | 受影响的命令 | 实测 wall | 产物与诊断 | 有意的行为差异 | +|---|---|---|---|---|---| +| 1 | 类型检查不打包地生成 Typert 声明 | `typecheck`、`lint`、`lint:fix`、`doc-typecheck`、CI `typert-contracts` | `typecheck` 34.1/35.0 → 21.9/20.0 s;前置步骤 52.2/29.6 → 12.5/11.3 s | 140 个 Typert 文件逐字节一致;检查的程序相同 | 不产出可运行 bundle,不写 client build record | +| 2 | pre-commit 钩子不重新生成第三方声明 | `pre-commit` | 16.5/17.8 → 4.3/4.5 s | 新鲜度由测试 lane 断言 | 第三方声明过期时在测试 lane 失败,而不是在提交时 | +| 3 | 本地默认 worker 预算 | run-gates 聚合、`build`、`typecheck`、`pnpm run test`、Oxlint、publint | 255 个 worker 时 `test` 为 243.7 s、55.3 GiB;`test:docs` 74.7 → 50.9 s | 检查不变 | 本地默认值不使用超过 12 或 48 的 CPU | +| 4 | 仓库引用候选对照一次对象列表解析 | `verify-repository-references` | 166.8 → 15.7 s | 27,050 个候选的判定 0 差异 | 无 | +| 5 | 文档门禁每个文档只解析一次 | markdown links、translation pairing、README model experience 与 limitations、type equivalence、Session format references | markdown links 53.4/51.6 → 25.9/25.1 s;type equivalence 23.0/22.5 → 6.0/5.5 s | 门禁输出逐字节一致 | 无 | +| 6 | 仓库文件遍历每个目录只列一次 | markdown wrap、doc refs、package paths | doc refs 8.8/7.0 → 3.2/3.4 s | 文件列表一致 | doc refs 不读取嵌套的 `lib` 目录 | +| 7 | CLI(命令行界面)help 读取并发运行;doc graphs 的探测做 memo | `verify-cli-help`、`verify-doc-graphs` | 49.9/53.9 → 6.9/7.2 s;48.7/44.7 → 32.7/34.2 s | 页面逐字节一致 | CLI help 峰值约用 3.6 GB 内存 | +| 8 | `type-model.spec.ts` 在内存工作区上运行 | `pnpm run test` | 316.3/329.1 → 14.65/15.04 s | 61 个用例与断言不变;快照有一行不同 | 除 Emitter 用例外,桩替代 zod、`@types/node` 与 DOM lib | + +### 测量条件 + +所有数字都是 wall 时间,取自同一个共享 Linux 容器:CPU 配额 64 核,宿主报告 256 个 CPU,一分钟 load average 为 20–150,因此样本带有噪声。工作树位于网络文件系统上,每次文件系统探测的代价都高于本地磁盘。`a/b` 表示两个样本,`x → y` 比较基线与本实现。 + +### 1. 类型检查不打包地生成 Typert 声明 + +- **目的:** 让 `typecheck` 以及 `lint`、`lint:fix`、`doc-typecheck` 与 CI `typert-contracts` 门禁的前置步骤得到它们读取的声明,即 `tsc` 产出的包声明与 Typert 声明,而不运行 Host tsdown 阶段或 Desktop bundle。 +- **改动:** `typecheck` 是 `tsx scripts/build.ts --scope typecheck`,它通过 run-gates 调度器运行下表中的步骤([`scripts/build.ts`](../../../../scripts/build.ts))。该 scope 不构建 native addon、tsdown bundle、Desktop bundle 或 Web 产物,不写 client build record,可运行的 `lib/` 产物保持上一次 `build:artifacts` 的状态;启动或测试构建产物之前先运行 `build:artifacts`。`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 的前置步骤是 `compile-referenced-projects.ts host-contracts`,它让 host-src 与 host-typert 并行([`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts))。`build`、`build:artifacts`、benchmark 构建与 `release-vendor.yml` 的 PR(Pull Request)演练在 Host tsdown 阶段内运行 Typert。 + +| 步骤 | 命令 | `needs` | +|---|---|---| +| host-src | `node --max-old-space-size=4096 -b ` | — | +| host-typert | `node --import tsx/esm scripts/generate-typert.ts` | — | +| client-src | ` -b ` | host-src、host-typert | +| host-check | `node --max-old-space-size=4096 -b tsconfig.host.json` | host-src | +| client-check | ` -b tsconfig.client.json` | client-src | + +- **共用生成:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) 对 `WORKSPACE_TYPERT_FACES`([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts),即 Host tsdown 插件同样生成的 face)调用 `WorkspaceTypertGenerator.generateContributors()`,并用 [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts) 中插件同样使用的 `writeWorkspaceArtifacts()` 写出结果。与插件一样,它跳过 TypeScript 诊断,这些诊断由 Host 包编译报告;Typert 分析错误使 host-typert 失败,正如它在 `build` 中使 Host bundle 失败。host-typert 由 Node 加 tsx 的 ESM 钩子运行,而不经 `pnpm exec`,因为后者的启动开销会拉长通往 Client 编译的路径。 +- **以 build mode 运行聚合检查:** host-check 与 client-check 在本侧 face 的包编译之后才开始,因此 build mode 发现所有 references 都是最新的,只检查聚合根程序,并在根程序的 tsbuildinfo 为最新时跳过它。 +- **只读源码的分析:** host-typert 能与 host-src 并行,是因为它的 Host program 不读取任何工作区构建产物。`tsconfig.base.json` 把 `@deepseek-ai/dsh-client-file-upload/types` 映射到源码;没有这条别名时,该说明符经包 `exports` 解析到 `lib/types/types.d.ts`。有了它,Host program 加载 1,289 个文件,其中没有一个位于工作区包的 `lib/` 下。`generate-typert.ts` 开启 `rejectBuildOutputs`:face program 加载工作区包 `lib/` 目录下的文件,或无法解析对工作区包的 import(例如包尚未编译的工作区)时,[`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) 以 `TypertAnalysisError` 使分析失败,并指出每个这样的 import 说明符,以及把它映射到源码的 `paths` 条目。因此无论 host-src 是否已运行,缺失的别名都会使生成失败。tsdown 插件不开启该选项,因为在 `build` 中 Typert 总在 Host 包编译之后运行。[Typert generator README](../../../../packages/typert/generator/README.zh.md) 记录了这些写入函数与该选项。 +- **无损依据:** 没有任何工作区 tsconfig 设置 `allowJs`,每个 `exports` 的 `types` 条件都指向 `lib/types/` 内或 `lib/typert.*`,每个 tsdown 配置都设置 `dts: false`,因此没有类型检查读取 tsdown 产物。140 个 Typert 文件(28 个包 × 5 个输出)在以下场景中逐字节一致:基线 tsdown 阶段、当前 tsdown 阶段、删除全部 Typert 输出后运行 `generate-typert.ts`、隐藏全部 344 个 `lib/types` 目录后运行 `generate-typert.ts`;临时给 `packages/goal/goal` 加一个 Remote 方法时,tsdown 阶段与 `generate-typert.ts` 写出的文件也相同。`typecheck` 检查两个 face 的全部包 project 与两个聚合根程序,即 `build` 检查的程序。 +- **性能:** 无需重查时 `typecheck` 为 34.1/35.0 → 21.9/20.0 s。两个聚合根程序都需要重查时为 83.5 → 76.0/72.1 s,其关键路径是约 66–70 s 的 Host 聚合检查。`lint` 与 `doc-typecheck` 的前置步骤为 52.2/29.6 → 12.5/11.3 s。 +- **取舍:** `typecheck`、`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 写出 Typert 声明但不写 bundle,因此在下一次 `build:artifacts` 之前,可运行的 `lib/` JavaScript 可能落后于这些声明。 +- **与构建的关系:** [无损构建提速 Note](2026-10-11-lossless-build-speedups.zh.md) 否决了在构建中把 Typert 移出 Host tsdown 阶段:构建中它的 Worker Thread 已与打包重叠,独立步骤最多只能省约 1 s。类型检查命令不运行打包器,因此不打包地生成会把整个 Host tsdown 阶段与 Desktop bundle 移出它们的关键路径。[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md) 列出每个调用方及其命令。 +- **已知缺口:** 增量 `tsc -b` 不把 Client 源码经包 `exports` 访问到的 `lib/typert.remote-client.d.ts` 记为消费方 project 的输入;Remote 签名改变而 Client 源码未变时,增量检查不会重查消费方。这一缺口与由哪一种生成写出声明无关,由 Issue #6022 跟踪。 + +### 2. pre-commit 钩子不重新生成第三方声明 + +- **改动:** [`lefthook.yml`](../../../../lefthook.yml) 中的 `pre-commit` 钩子没有第三方声明任务。[`gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 在测试 lane(包括必过的 `node 24 / coverage` lane)中断言 `THIRD_PARTY_NOTICES.md` 已提交的字节,过期时指出 `pnpm run gen-third-party-notices`。[生成的第三方声明 Note](2026-07-30-generated-third-party-notices.zh.md) 拥有这一新鲜度决策。 +- **性能:** 暂存一个位于包 `src/` 子目录下的文件的提交,钩子耗时由 16.5/17.8 s 降为 4.3/4.5 s,其中第三方声明任务占 12.4/13.4 s。 +- **取舍:** 使第三方声明过期的提交会在推送后于测试 lane 失败,而不是在提交时被修正。 + +### 3. 本地默认 worker 预算 + +- **目的:** 按共享工作站或容器确定默认并行度;在这类环境中 Node 报告的是宿主 CPU 数而不是 CPU 配额。 +- **改动:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) 拥有默认值。`CI=true` 时它是 Node 报告的 CPU 数。否则,run-gates 调度器、`build`、`typecheck`、Oxlint 与 publint 的上限为 12(`LOCAL_PARALLELISM_CAP`),Vitest fork 与本地覆盖率预算的上限为 48(`LOCAL_TEST_PARALLELISM_CAP`)。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 的每个门禁都构建完整的 `ts.Program`,它们最多并发 8 个门禁,CI 中同样如此。显式的 `DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest `--maxWorkers` 优先;CI 中,除非 run-gates 的 CI 环境设置了这些变量,Vitest 与 Oxlint 使用各自的默认值。[开发指南](../../../../docs/development.zh.md)向贡献者说明这些默认值。 + +四种 fork 数下的 `pnpm run test`,运行的是第 8 项所替换的落盘版 `type-model.spec.ts`,32 个 worker 那次的 wall 由它决定: + +| Vitest worker 数 | wall | RSS 峰值 | 额外的偶发失败 | 除最慢文件外其余文件完成于 | +|---|---|---|---|---| +| 255(报告的 CPU 数) | 243.7 s | 55.3 GiB | 12 个文件、14 次超时 | 约 135–170 s | +| 32 | 310.4 s | 15.7 GiB | 4 个文件、4 次超时 | 193 s | +| 24 | 337.2 s | 13.2 GiB | 5 个文件、5 次超时 | 233 s | +| 12 | 410.5 s | 8.4 GiB | 2 个文件、3 次超时 | 406 s | + +- **Oxlint:** Oxlint 自身的默认值是 64 线程(读取 cgroup 配额);12 线程时其 wall 保持约 87–90 s,CPU 少约 15%;8 线程时慢约 4 s。 +- **文档聚合:** 并发 8 个门禁时 `test:docs` 为 50.9 s,并发 4 个时为 74.7 s。不含第 4 项时测得的 `doc-sync` 在并发 4、8、12 个门禁时分别为 341.8、295.0、253.2 s,RSS 峰值分别为 10.0、13.5、16.3 GiB。 +- **取舍:** CPU 配额超过上限的机器,默认并行度也不超过 12 或 48,需要使用覆盖变量;配额低于上限的容器仍会超额订阅,因为 Node 报告的是宿主 CPU 数。并发 8 个门禁时 `doc-sync` 的 RSS 峰值为 13.5 GiB,并发 4 个时为 10.0 GiB;并发 12 个门禁快 41.8 s,但峰值为 16.3 GiB。CI 的文档 workflow 运行 `doc-sync` 时同样最多并发 8 个门禁。 + +### 4. 仓库引用候选对照一次对象列表解析 + +- **改动:** [`verify-repository-references.ts`](../../../../scripts/verify-repository-references.ts) 用 `git cat-file --batch-all-objects --batch-check=%(objectname)` 一次列出全部对象名(约 120 万个对象,0.6 s),排序后在内存中对每个小写候选前缀做二分查找;随后一次 `--batch-check=%(objectname) %(objecttype)` 调用读取命中对象的类型。只有当候选前缀恰好匹配一个可用对象且该对象是 commit 时,它才算 commit 引用,这与 Git 自身前缀查找的规则相同;与 Git 的查找一样,该列表也包含不可达对象与 alternate 对象。 +- **性能:** 166.8 → 15.7 s。 +- **无损依据:** 在 27,050 个真实与合成候选上(包括歧义前缀、blob 与 tree 前缀以及随机字符串),Git 的逐候选前缀查找与内存查找的判定完全一致。 +- **取舍:** 120 万个对象的列表约为 49 MiB 的 Git 输出,读取时的输出上限为 1 GiB。 + +### 5. 文档门禁每个文档只解析一次 + +- **改动:** 下表中的每个门禁对一个 Markdown 文档只解析一次,并把语法树传给本次运行中的所有使用方。 + +| 门禁 | 单次解析机制 | wall | +|---|---|---| +| markdown links([`verify-md-links.ts`](../../../../scripts/verify-md-links.ts)) | 一个按运行划分的缓存对每个文件只解析一次,同时取得其 destination 与锚点,并对每个解析后的目标只探测一次 | 53.4/51.6 → 25.9/25.1 s | +| translation pairing([`verify-translation-pairing.ts`](../../../../scripts/verify-translation-pairing.ts)) | 一次 glob 覆盖所有范围 pattern;record 在下述守卫条件下复用原文语法树 | 51.0/47.1 → 30.3/29.3 s | +| package README model experience、package README limitations | 每项检查对一个 README 只解析一次,并把语法树传给 `markdownProseLines()` 与 `markdownHeadingLines()` | 各约 12 → 5.9 s | +| type equivalence([`verify-type-equiv.ts`](../../../../scripts/verify-type-equiv.ts)) | 文本预筛跳过不可能含源码等价围栏的文档;manifest(元数据清单)中的每个 TypeScript 源文件只解析一次 | 23.0/22.5 → 6.0/5.5 s | +| Session format references | persistence 渲染器把它解析出的语法树传给配对 record | 10.2 → 5.3 s | + +- **配对复用守卫:** [`translation-pairing-record.ts`](../../../../scripts/translation-pairing-record.ts) 中的 `normalizedTopLevelBlocks()` 仅在以下条件同时成立时,才为归一化后的文本复用原文语法树:每处链接归一化编辑都替换一个完整、无 title 且不在行首开始的 destination,原文等于该节点的 `url`,且原文与替换文本都只含 `[A-Za-z0-9._/:#%-]`。这些字符既不能结束 destination,也不能开始或结束其他语法结构,因此归一化后的文本解析出相同的节点,只是偏移量随之移动。其他任何编辑都回退为对归一化文本的完整解析。 +- **type equivalence 预筛:** [`type-equiv-prefilter.ts`](../../../../scripts/type-equiv-prefilter.ts) 在文档含 `type-equiv` 或 `public-api`,或者某个含 `` ``` `` 或 `~~~` 的行同时含 `&` 或 `\` 时,保留该文档进行解析。解析只会通过解码字符引用与反斜杠转义改变信息字符串,因此 `ts type-equiv` 与 `ts type\-equiv` 所在的文档都会被保留;[`type-equiv-prefilter.spec.ts`](../../../../scripts/type-equiv-prefilter.spec.ts) 覆盖这两种写法。 +- **无损依据:** 每个门禁在仓库上的输出逐字节一致,人为注入的错误也被同样报告。对配对而言,语料中全部 1,886 个文档的顶层块与全部 943 个配对的 record 都与完整重新解析的结果一致,随机 destination 的结果也一致。 + +### 6. 仓库文件遍历每个目录只列一次 + +- **改动:** [`scripts/repo-files.ts`](../../../../scripts/repo-files.ts) 中的 `uniqueRepoFiles()` 在一次调用的所有 pattern 之间共享一个目录列表缓存,使每个目录最多读取一次,并且只对经符号链接到达的匹配调用 `realpath`;只经真实目录到达的普通文件,规范路径为仓库 realpath 拼接其相对路径。它的可选谓词 `skipDirectory` 让遍历器不进入调用方排除其下全部路径的目录;[`verify-doc-refs.ts`](../../../../scripts/verify-doc-refs.ts) 跳过第一段路径之后的每个 `lib` 目录,其下文件本来就全部被它排除。 +- **性能:** markdown wrap 25.8/25.0 → 21.3/21.2 s;doc refs 8.8/7.0 → 3.2/3.4 s;package paths 9.9/8.1 → 7.2/5.5 s。 +- **无损依据:** 三个门禁得到的文件列表逐项相同,损坏或循环的符号链接仍会经 `realpath` 失败。 +- **行为差异:** doc refs 不列出被跳过的 `lib` 目录,因此不可读的此类目录不会以 EACCES 使门禁失败。 + +### 7. CLI help 读取并发运行;doc graphs 的探测做 memo + +- **CLI help:** [`scripts/gen-cli-help.ts`](../../../../scripts/gen-cli-help.ts) 中的 `prefetchHelp()` 在默认 worker 预算内启动 12 个顶层 `dsh … --help` 读取(启动器、`plugin`,以及每个 profile 的简写与 `--profile` 形式),每个读取使用独立的临时 `DSH_HOME` 与 `DSH_AGENTS_HOME`,并由按原顺序进行的遍历消费;嵌套读取共享同一上限。失败的读取在遍历请求该调用时才交给遍历;`close()` 中止排队与运行中的读取,并等待每个已启动的子进程结束。该门禁为 49.9/53.9 → 6.9/7.2 s。 +- **doc graphs:** [`scripts/gen-doc-graphs.ts`](../../../../scripts/gen-doc-graphs.ts) 中的 `withMemoizedFileSystemProbes()` 在 `renderDocs` 运行期间对 `ts.sys` 的 `fileExists`、`directoryExists`、`realpath`、`readDirectory` 与 `getDirectories` 做 memo,使一次运行中的所有编译器 host 与配置解析共享同一个文件系统视图。运行期间工作区不得改变,写出页面之前恢复原方法。该门禁为 48.7/44.7 → 32.7/34.2 s。 +- **无损依据:** 两个生成器生成的页面逐字节一致。 +- **取舍:** 并发的 help 进程合计峰值约占 3.6 GB 内存。 + +### 8. `type-model.spec.ts` 在内存工作区上运行 + +- **改动:** [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 用 [`type-model-workspace.ts`](../../../../packages/typert/generator/tests/type-model-workspace.ts) 中的 `memoryFileSystem()` mock `node:fs` 与 `ts.sys` host,使内存根目录下的 fixture 复制、用例编辑、写模式注解、tsconfig 展开与模块解析都不触及磁盘。每个工作区挂载最小的 `zod` 与 `@types/node` 声明桩;只有 Emitter 用例保留真实 zod。fixture 的 `tsconfig.base.json` 设置 `lib: ["ES2024"]`,不含 DOM lib;读取未修改 fixture 的用例共享一份深度冻结的分析结果。generator 源码没有任何测试专用路径。 +- **性能:** 单独运行该文件为 316.3/329.1 → 14.65/15.04 s。 +- **行为覆盖保持不变的依据:** 61 个用例名及其断言相同。快照只有一行不同,即外部 zod 符号路径,它指向桩(`:node_modules/zod/index.d.ts#ZodType`),而不是某个 pnpm store 版本。 +- **取舍:** 除 Emitter 用例外,该 spec 不针对真实文件系统、DOM lib 以及真实 zod 与 `@types/node` 声明检验分析器。内存文件系统只实现分析器使用的 `node:fs` 调用(`existsSync`、`readFileSync`、`realpathSync` 与 `writeFileSync`);对内存路径的其他调用会落到真实文件系统,而该路径并不存在。 + +### 全仓对比 + +两棵树在同一时段测量,每个命令在 `pnpm run build:artifacts` 之后交替运行基线与本实现(`pnpm run test` 在两棵树上失败的都是依赖环境的文件,基线 42 个,本实现是其中的 28 个): + +| 命令 | 基线 | 本实现 | 变化 | 对应项 | +|---|---|---|---|---| +| `pre-commit` 钩子,暂存一个位于包 `src/` 子目录下的文件 | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 | +| `pnpm run typecheck`(pre-push),无需重查 | 29.7/23.3 s | 14.2/16.1 s | −43% | 1、3 | +| `pnpm run lint` | 110.0 s | 106.7 s | −3% | 1 | +| `pnpm run doc-typecheck` | 45.2 s | 36.1 s | −20% | 1 | +| `pnpm run test:docs` | 102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 | +| `pnpm run doc-sync` | 186.8 s | 70.3 s | −62% | 3–7 | +| `pnpm run test` | 222.7 s(255 个 worker) | 148.1 s(48 个 worker) | −34% | 3、8 | + +`lint` 的耗时主要是 Oxlint 本身,其前置步骤的下降见第 1 项。CPU 时间随 wall 一同下降:`typecheck` 的 user 时间 39.5 → 14.2 s,`test:docs` 212.6 → 126.3 s,`doc-sync` 628.2 → 503.2 s,`test` 3,417 → 3,014 s。 + +## 考虑过的替代方案 + +- **所有本地默认值一律以 12 为上限,包括 Vitest fork(第 3 项):** 12 个 worker 时 `pnpm run test` 为 410.5 s,255 个时为 243.7 s,慢 68%。 +- **以 cgroup CPU 配额的一半作为本地预算(第 3 项):** 读取配额的改动更大,且只在容器内有意义。 +- **用 `worker_threads` 并行解析文档(第 5 项):** 它带来的复杂度高于每个文档只解析一次,而后者直接消除了重复工作。 +- **在 pre-commit 钩子中重新生成第三方声明(第 2 项):** 浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每次提交都运行约 16 秒的生成器,而改变输出的提交很少;[生成的第三方声明 Note](2026-07-30-generated-third-party-notices.zh.md) 拥有该决策。 + +## 后果 + +无需重查时 pre-push `typecheck` 约 14–22 s,`lint` 与 `doc-typecheck` 的前置步骤约 12 s,改动 1 个文件的提交的钩子约 4 s。`pnpm run test` 在本地最多启动 48 个 fork,`test:docs` 与 `doc-sync` 最多同时运行 8 个门禁。Typert 输出、生成的页面、文件列表与引用判定都与基线逐字节一致。 + +代价:类型检查命令不刷新可运行的 bundle;第三方声明过期时在推送后于测试 lane 失败;本地默认值不使用超过上限的 CPU,而配额低于上限的容器仍会超额订阅;并发 8 个文档门禁抬高 `doc-sync` 的 RSS 峰值;CLI help 峰值约用 3.6 GB 内存;doc graphs 与 markdown links 假定运行期间工作树不变;除 Emitter 用例外,`type-model.spec.ts` 检验的是桩,而不是真实 zod、`@types/node`、DOM lib 与磁盘 I/O;增量 `typecheck` 继承 Issue #6022 跟踪的 Remote 声明缺口。 + +验证:`generate-typert.spec.ts` 固定与 workspace 模式插件逐字节一致的文件、对加载已编译工作区声明的 Host program 的拒绝(并指出缺失的别名),以及每个失败阶段;`compile-referenced-projects.spec.ts` 固定 `host-contracts` 的步骤及其失败报告;`build.spec.ts` 固定 typecheck scope 的步骤;`parallelism.spec.ts`、`run-gates.spec.ts` 与 `run-oxlint.spec.ts` 固定 CI 与本地预算以及覆盖变量;`repo-files.spec.ts`、`markdown.spec.ts`、`translation-pairing.spec.ts`、`type-equiv-prefilter.spec.ts`、`verify-md-links.spec.ts`、`gen-cli-help.spec.ts` 与 `gen-doc-graphs.spec.ts` 固定目录只列一次、语法树复用、配对守卫、预筛、预取顺序与失败路径,以及探测 memo 及其恢复。 diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml index e5f30837c8..9f7d9ba29e 100644 --- a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml @@ -9,8 +9,8 @@ en: 8d63b992ff6d3161 zh: 86d333255a5400d3 /agent-note-isolate-installed-update-materials-from-releases-and-user-data/decision: - en: 815a1c062134d958 - zh: 05d7d89099e6911c + en: 4b2464efa4292cfd + zh: 449d6ea0d903d7e7 /agent-note-isolate-installed-update-materials-from-releases-and-user-data/alternatives-considered: en: c608f186cc94a9d3 zh: 39750c8347e8bece diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md index 24209c1cce..88129f2cc2 100644 --- a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md @@ -10,7 +10,7 @@ An installed-update walkthrough needs two increasing versions with a shared appl ## Decision -Version derivation follows the [release version reference](../../../../apps/desktop/README.md); this record continues to govern material, application identity, and user-data isolation. +Version derivation follows the [release version reference](../../../../apps/desktop/packaging/README.md#release-versions); this record continues to govern material, application identity, and user-data isolation. The [material preparer](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts) accepts a retained test-only manifest with a random identity and distribution namespace. It copies a verified source runtime into separate derived test versions, changes only release-family versions and matching dependency references, and seals and verifies both inventories. The original runtime is verified again and its descriptor hash must remain unchanged. Existing output directories are not overwritten; a failed copy retains a failure record and cannot create a completion receipt. diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md index 22ceec3f7b..d0f9aa2bae 100644 --- a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -版本派生遵循[发布版本说明](../../../../apps/desktop/README.zh.md);本记录继续规定物料、应用身份和用户数据的隔离。 +版本派生遵循[发布版本说明](../../../../apps/desktop/packaging/README.zh.md#release-versions);本记录继续规定物料、应用身份和用户数据的隔离。 [物料准备器](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts)接受保留的 test 专用清单,其中包含随机身份和分发命名空间。它将已验证的原始运行时复制为独立的派生测试版本,仅修改发布家族版本和匹配的依赖引用,再生成并验证两个完整性清单。原始运行时再次验证,其描述文件哈希必须保持不变。已有输出目录不被覆盖;复制失败保留失败记录,不能生成完成回执。 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml new file mode 100644 index 0000000000..7c41c4d039 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-11-user-text-credential-matching.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +/agent-note-linear-credential-matching-and-performance-gates-for-user-text: + en: 4d3e2c3ad91a1a03 + zh: d08dd77dd0bd849c +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/problem: + en: ae449178436337ea + zh: 9eced057b7d9ef3d +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision: + en: a15c7c13f167e5ac + zh: ff261e3705c47ff0 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/algorithm-what-each-part-does-and-costs: + en: aa1d65cd64a2a895 + zh: 4bf32f20d14ce452 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/memory: + en: b91fc88cdd488ce3 + zh: 318db3f522681cca +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/trade-offs: + en: f07bb6fbbdd01fe4 + zh: 9313a0ef2a22af12 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: + en: 6a303115f85ed4e8 + zh: e894f3e9d630bf57 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: + en: 1e21f5f9ca959f7e + zh: 401e303071b8d426 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: + en: 8f9d25b72d3e7660 + zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md new file mode 100644 index 0000000000..d7995a1f93 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -0,0 +1,67 @@ +# Agent Note: Linear credential matching and performance gates for user text + +Status: implemented + +English | [中文](2026-10-11-user-text-credential-matching.zh.md) + +## Problem + +[ui-secret-mask](../../../../packages/client/ui-secret-mask/README.md) recognizes credentials in every sent user text. The UserText scheduler queries the matcher again after each accepted node, so a text costs the sum of all its queries. Most text contains no credential, so the first goal is that such text pays as little as possible; second, text with credentials pays in proportion to their number and length; adversarial input must stay linear. Constraints: no regex backtracking, prefix pruning, linear total over all queries of one text, and no allocation before a node is returned. + +## Decision + +### Algorithm: what each part does and costs + +| Part | Method | Cost over all queries of one text | +|---|---|---| +| Finding format starts | All starting literals (`sk-`, `sk_live_` and kin, `AIza`, `ghp_` and kin, `github_pat_`, `glpat-`, `npm_`, `hf_`, `AKIA`/`ASIA`, `LTAI`, `AKID`, `xox?-`, `whsec_`, `eyJ`, `-----BEGIN `, `://`) form one literal-only alternation grouped by shared leading characters, scanned with `test` | Θ(n) native comparisons; no quantifier, so no backtracking; two adjacent queries reread at most one literal | +| Overlapping literals | Where a literal's suffix begins another literal (the `-` of `sk-`, `glpat-`, `xox?-` and `-----BEGIN `), the scan resumes at that suffix; resume offsets are computed from the literal table at module load | At most one character back per hit | +| Identifying the hit | A table keyed by the hit's last two code units; only the three shared endings (`t_`, `e_`, `IA`) are told apart with `startsWith` | O(1) per hit | +| Format checks | Word boundaries, lengths, digit position, and the `.eyJ` separator are numeric comparisons; a private-key label reads at most 52 characters and a scheme at most 16 back; character runs are measured natively with sticky `[class]*` patterns, which stop at the first character outside the class and have nothing after them to backtrack into | O(1) per hit plus run measurement | +| Runs, next digit, next `END` line | Each matcher instance memoizes per text in one `Int32Array`: four slots for `[A-Za-z0-9_-]` (a JWT uses three runs at once), one slot per other class, the next digit, and the next valid `END` line. These are facts of the text, reused across queries and cleared when a query names another text | Each character measured at most once per class | +| Address passwords | When the `://` precedes the query offset and the password follows it, the matcher first confirms that `:`, a nonempty password, and `@` follow the offset, and only then walks back over the user name; an offset inside `://` is checked directly | At most 258 characters back, only where `:password@` follows the offset | +| Joining candidates | Candidates are processed in start order; an address password starts after its `://` and waits until the scan passes its start, and no other `://` can occur before then because user names and passwords exclude `/`; overlapping candidates join the earliest and take its mask; a range that reaches the end of the text ends the scan | O(1) per candidate, no list | +| Masks | A range of prefixed formats only is ASCII without line breaks, so its mask is a slice of its first 8 characters plus a slice of a shared mark string; a private-key body skips ordinary characters natively and takes one slice of a shared "marks plus line break" string per line; address passwords and other mixed ranges count code points character by character | Θ(masked characters + lines), the order of the output | + +### Memory + +- Nothing is allocated before a node is returned: `test` creates no match array, memo slots are a preallocated integer array, loops use indexes instead of iterators, and candidate ranges live in local numbers. +- Each credential allocates only its result: the node, its `data` object, and the mask. A one-line mask is the concatenation of two slices; a multi-line mask adds one slice and one concatenation per line. +- Each instance keeps a reference to the last queried text until a query names another text. + +### Trade-offs + +- **Scanning is native, decisions are JavaScript**: in V8 a per-character JavaScript loop is slower than a native literal scan or native run measurement, so all work proportional to input length (finding starts, measuring runs, skipping key bodies) uses non-backtracking native patterns, and JavaScript makes a constant number of decisions per hit. +- **Pruning depends on the formats only**: starts come from format literals, not from anchors chosen by character frequency in natural text, so text with any character distribution pays the same linear cost. +- **Memo per text, not per query**: a memo inside one query cannot stop later queries from measuring the same characters again; keyed by text and owned by the instance, cleared on a new text, it costs one retained string reference. +- **Masks use shared-string slices**: faster than writing into a buffer and decoding it; a multi-line mask allocates in proportion to its lines, which is part of the output. + +### Performance gate + +[benchmarks/user-text](../../../../benchmarks/user-text/user-text.bench.client.ts) runs in the required `node 24 / benchmarks` lane. Its compiled worker uses the production UserText registry and every shipped matcher (session, file, folder, skill, command, whitespace, credential) under plain Node, in two processes: timing and allocation. + +| Item | Content | +|---|---| +| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | +| Timing | Each sample builds a fresh registry, document, and matcher instance and times evaluation only; each workload is measured with and without the credential matcher, keeping the fastest of several samples and the process CPU time over the same interval | +| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | +| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | +| Excluded | React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | + +Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; time ratios, doubling ratios, and allocation budgets do not scale with machine speed. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset. + +## Alternatives considered + +- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. +- **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure. +- **Scanning for rare characters first, then looking back for prefixes**: faster on ordinary text, but cost depends on character distribution, and text dense in `-` or `_` becomes clearly slower. +- **A pure JavaScript Aho–Corasick automaton**: allocation-free, but per-character table lookups carry a state dependency and are slower than the native literal scan, so the most common text, without credentials, gets slower. +- **A memo rebuilt per query**: with accepted tokens between digit-free `sk-` prefixes, every query's digit search reaches the end of the text, which grows quadratically. +- **Building private-key masks in one buffer with `TextDecoder`**: one string per block, but each decode's fixed cost exceeds shared-string slices. +- **`exec` for hits and `for…of` over memo slots**: they allocate match arrays and iterator results. + +## Consequences + +- The added cost on text without credentials is one native scan plus checks at literals; it cannot drop further without depending on character distribution. +- A new format adds its literal and a forward-only check to the format table; resume offsets and the hit table are derived. The literal-pattern unit test rejects quantifiers, and the benchmark rejects superlinear scans and extra allocation. +- The plugin creates and owns the matcher instance in `apply`; tests and the benchmark create one per sample and share no memo. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md new file mode 100644 index 0000000000..4da050e2e4 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -0,0 +1,67 @@ +# Agent Note: 用户文本凭据识别的线性匹配与性能门禁 + +Status: implemented + +[English](2026-10-11-user-text-credential-matching.md) | 中文 + +## 问题 + +[ui-secret-mask](../../../../packages/client/ui-secret-mask/README.zh.md) 在每条已发送用户文本上识别凭据。UserText 调度器在每个被采纳的节点之后再查询一次匹配器,一段文本的成本是它全部查询之和。大部分文本不含凭据,所以首要目标是让不含凭据的文本尽量少付成本;其次是含凭据文本的成本只与凭据个数和长度成正比;对抗输入必须保持线性。约束:正则不回溯;按前缀剪枝;一段文本的全部查询合计线性;返回节点之前不分配内存。 + +## 决定 + +### 算法:每部分的做法与复杂度 + +| 部分 | 做法 | 一段文本全部查询的代价 | +|---|---|---| +| 找格式起点 | 所有起始字面量(`sk-`、`sk_live_` 等、`AIza`、`ghp_` 等、`github_pat_`、`glpat-`、`npm_`、`hf_`、`AKIA`/`ASIA`、`LTAI`、`AKID`、`xox?-`、`whsec_`、`eyJ`、`-----BEGIN `、`://`)按共同前缀分组成一个只含字面量的交替正则,用 `test` 扫描 | Θ(n) 次原生比较,没有量词因而不回溯;相邻两次查询至多重读一个字面量 | +| 重叠的字面量 | 字面量的后缀若是另一个字面量的开头(`sk-`、`glpat-`、`xox?-` 的 `-` 与 `-----BEGIN `),扫描从该后缀处继续;续扫位置在模块加载时由字面量表算出 | 每个命中至多回退一个字符 | +| 识别命中的字面量 | 命中末尾两个码元查表;只有共享结尾的三组(`t_`、`e_`、`IA`)再用 `startsWith` 区分 | 每个命中 O(1) | +| 格式校验 | 单词边界、长度、数字位置、`.eyJ` 分隔用数值比较;私钥标签至多读 52 个字符,协议名至多回看 16 个字符;字符段用粘性 `[字符类]*` 原生测量,模式在第一个不属于该类的字符处停止,后面没有内容,不回溯 | 每个命中 O(1) 加字符段测量 | +| 字符段、下一个数字、下一行 `END` | 每个匹配器实例用一个 `Int32Array` 按文本记忆:`[A-Za-z0-9_-]` 四个槽(JWT 同时用三段),其余字符类各一个槽,另记下一个数字和下一行有效 `END` 的位置;这些是文本本身的事实,跨查询复用,查询换成另一段文本时清空 | 每类字符每个字符至多测量一次 | +| 地址密码 | `://` 在查询起点之前、密码在起点之后时,先确认起点后面紧跟 `:`、非空密码和 `@`,才回看用户名找 `://`;起点落在 `://` 中间时直接检查 | 回看至多 258 个字符,且只在 `:密码@` 紧跟起点时发生 | +| 合并候选 | 候选按起点顺序处理;地址密码在其 `://` 之后开始,延后到扫描越过它的起点,此前不会出现另一个 `://`(用户名和密码不含 `/`);重叠的候选并入最早的候选并使用其遮挡方式;范围到文本末尾即停止扫描 | 每个候选 O(1),不建列表 | +| 遮挡串 | 只由前缀格式组成的范围全是 ASCII 且没有换行,遮挡串是前 8 个字符的切片加一个共享星号串的切片;私钥正文用原生模式跳过普通字符,每行取一个"星号加换行"共享串的切片;地址密码和其他混合范围逐字符计数码点 | Θ(遮挡字符数 + 行数),与输出同阶 | + +### 内存 + +- 返回节点之前不分配:`test` 不产生匹配数组,记忆槽是预先分配的整数数组,遍历用下标而不是迭代器,候选范围存在局部数值里。 +- 每个凭据只分配结果:节点、`data` 对象和遮挡串。单行凭据的遮挡串是两个切片的拼接;多行遮挡每行再加一个切片和一次拼接。 +- 每个实例保留最近一次查询的文本引用,直到查询另一段文本。 + +### 取舍 + +- **扫描交给原生正则,判断交给 JavaScript**:V8 中逐字符的 JavaScript 循环比原生字面量扫描和原生字符段测量慢;凡是与输入长度成正比的工作(找起点、量字符段、跳过私钥正文)都用不回溯的原生模式,JavaScript 只做每个命中常数次的判断。 +- **只依赖格式本身的剪枝**:起点由格式字面量决定,不按字符在自然文本中的频率挑锚点,因此任何字符分布的文本都付同样的线性成本。 +- **记忆按文本而不按查询**:一次查询内的记忆不能阻止后续查询重新测量同一段字符;以文本为键、由实例持有,换文本即清空,代价只是保留一个字符串引用。 +- **遮挡串用共享串切片**:比先写入缓冲区再整体解码更快;多行遮挡的分配与行数成正比,仍属于输出本身。 + +### 性能门禁 + +[benchmarks/user-text](../../../../benchmarks/user-text/user-text.bench.client.ts) 在 `node 24 / benchmarks` 必跑 lane 中运行。编译后的 worker 使用生产的 UserText 注册表和全部已发布匹配器(会话、文件、文件夹、skill、命令、空白、凭据),在纯 Node 下分两个进程运行:计时与分配。 + +| 项 | 内容 | +|---|---| +| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | +| 计时 | 每个样本新建注册表、文档和匹配器实例,只计求值;每个负载分别测有无凭据匹配器,取多个样本中最快的一个,并记录同一区间的进程 CPU 时间 | +| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | +| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | +| 排除 | React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | + +预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;耗时比、翻倍比和分配预算不随机器缩放。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。 + +## 考虑过的替代方案 + +- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 +- **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。 +- **先找稀有字符再回看前缀**:普通文本扫描更快,但成本取决于字符分布,`-`、`_` 密集的文本明显变慢。 +- **纯 JavaScript 的 Aho–Corasick 自动机**:可以零分配,但逐字符查表有状态依赖,比原生字面量扫描慢,最常见的无凭据文本反而变慢。 +- **每次查询新建记忆**:被接受的 token 之间夹着无数字的 `sk-` 时,每次查询的数字查找都扫到文末,平方增长。 +- **用 `TextDecoder` 从缓冲区一次生成私钥遮挡串**:每块只分配一个字符串,但每次解码的固定开销高于共享串切片。 +- **用 `exec` 取命中、用 `for…of` 遍历记忆槽**:分别分配匹配数组和迭代器结果。 + +## 影响 + +- 无凭据文本的额外成本是一遍原生扫描加字面量处的校验;不依赖字符分布就无法再降。 +- 新增格式要在格式表里加字面量和只向前的校验;续扫位置与命中识别表自动生成。字面量正则的单测拒绝量词,benchmark 拒绝超线性扫描和多余分配。 +- 匹配器实例由插件在 `apply` 中创建并持有;测试和 benchmark 每个样本新建实例,不共享记忆。 diff --git a/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.i18n.yaml new file mode 100644 index 0000000000..29a80e39fa --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-09-29-session-migration-follow-redirect.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.md +/agent-note-present-single-session-migration-through-follow-redirection: + en: 294f1872bbef5f59 + zh: c4bf2852374fbba4 +/agent-note-present-single-session-migration-through-follow-redirection/problem: + en: 8d019bda1e854df3 + zh: 49cbd8b8d869e406 +/agent-note-present-single-session-migration-through-follow-redirection/proposal: + en: b5d43cb845d165ed + zh: c5b3aa8eb7799134 +/agent-note-present-single-session-migration-through-follow-redirection/proposal/completion-milestone-remains-open: + en: 2281727adab80d51 + zh: 4795be5613b37ede +/agent-note-present-single-session-migration-through-follow-redirection/alternatives-considered: + en: bfc5d2325e710601 + zh: 4f285a9f317cd6af +/agent-note-present-single-session-migration-through-follow-redirection/acceptance-criteria: + en: e5d810300cec5f99 + zh: 7bcef7a5ef99501e +/agent-note-present-single-session-migration-through-follow-redirection/risks: + en: 1704cb15dc3c0b5e + zh: cc4576d6e47c3b22 diff --git a/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.md b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.md new file mode 100644 index 0000000000..282722f2eb --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.md @@ -0,0 +1,43 @@ +# Agent Note: Present single-Session migration through follow redirection + +Status: proposed + +English | [中文](2026-09-29-session-migration-follow-redirect.zh.md) + +## Problem + +Opening historical Sessions can wait for lengthy format conversion. Ordinary loading cannot explain the current phase or show migration failure; placing progress inside history events would contaminate the model log and change journal sequence semantics. + +## Proposal + +Add a typed business redirect to single-Session history reads: page or follow returns a migration reference when preparation is required, the Client follows that migration for its current phase and subsequent progress, then returns to the original history read after success. “302” denotes a business result, not a browser HTTP redirect, and accepts no arbitrary URL. + +The Session object layer owns redirection, waiting, failure, and reopening. React must observe an explicit migration state and render a dedicated component; failure retains its cause and retry action. Migration success returns only to history loading, and a valid snapshot establishes history readiness. Paging and reconnect retain existing history and drafts. + +The actual persistence preparation owner retains migration ownership; multiple waiters share work and late joiners receive current state immediately. Closing one view releases only its waiter. Started durable publication retains its run-to-settlement policy. Progress enters neither the Session log nor `session.control` for this flow. + +Create/adopt and full-text search keep automatic processing; fork and catalog reads use the [migration prerequisite](../../implemented/architecture/2026-09-29-explicit-session-history-preparation.md). + +### Completion milestone remains open + +Read-open currently produces validated in-memory history; write-open encodes, verifies, and publishes a new generation. If redirection waits only for read preparation, its reference must retain the prepared result until the original follow takes ownership rather than relying on an evictable short-lived cache. Waiting for publication instead adds write-permission and publication-time requirements to opening old history. Implementation must choose this behavior rather than using an ambiguous done state for both milestones. + +## Alternatives considered + +**Push migration progress through global control.** One history-opening operation can own its waiting, failure, and retry without independent global progress synchronization. + +**Insert progress before the ordinary history journal's opening frame.** The generic journal remains snapshot-first; the Session adapter consumes redirection and migration frames, forwarding only ordinary history to the journal. + +**Automatically replay every RPC that encounters migration.** Commands with committed effects cannot be replayed safely. This proposal covers recoverable history reads rather than a general command-retry platform. + +## Acceptance criteria + +- The Client shows actual migration phases before receiving the history snapshot; failure remains visible without an automatic retry loop. +- Concurrent joiners, independent waiter cancellation, and reconnect observe the same operation's current state; an old attempt cannot overwrite a new one. +- The resumed history read cannot restart the same migration after cache eviction; cancellation never rolls back a published generation. +- Unknown totals produce no fabricated percentages. Unrelated lightweight requests and progress frames receive execution time during migration, with responsiveness measured separately. +- Create/search, fork/catalog policies, and existing Session data retain their respective guarantees. + +## Risks + +Redirection spans two requests and needs explicit retention and cancellation ownership. Progress alone does not remove synchronous Host parsing and validation stalls; scheduling changes require measurements. The choice between read preparation and write publication remains an implementation prerequisite. diff --git a/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.zh.md b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.zh.md new file mode 100644 index 0000000000..5dbc213167 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 单会话迁移通过 follow 重定向呈现 + +Status: proposed + +[English](2026-09-29-session-migration-follow-redirect.md) | 中文 + +## 问题 + +打开历史会话可能等待较长的格式转换。普通 loading 无法解释当前阶段或显示迁移失败;把进度放入历史事件又会污染模型日志,并改变历史流的序号语义。 + +## 提案 + +对单会话历史读取增加 typed 业务重定向:page 或 follow 在需要迁移时返回迁移引用,Client 转到迁移 follow,接收当前阶段和后续进度;成功后回到原历史读取。这里的“302”是业务结果,不是浏览器 HTTP 跳转,也不接受任意 URL。 + +Session 对象层编排跳转、等待、失败和重新读取。React 必须看到迁移中的明确状态并显示专门组件;失败保留原因和重试入口。迁移成功只回到历史加载态,收到有效 snapshot 才表示历史就绪。分页或重连已经拥有的历史与草稿保留。 + +迁移操作仍由实际持久化准备入口拥有;多个等待者共享任务,后加入者立即取得当前状态。关闭一个视图只释放其等待,不取消其他等待者。已开始的 durable publication 遵守既有运行至结算规则。进度不写入 Session log,不使用 `session.control` 作为本流程的进度通道。 + +create/adopt 和全文搜索保持自动处理;fork 和目录读取采用[迁移前置检查](../../implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md)。 + +### 完成里程碑仍待确定 + +现有 read-open 只生成经验证的内存结果;write-open 才编码、校验并发布新 generation。若重定向只等待读准备,迁移引用必须将准备结果保留到原 follow 接手,不能依赖可淘汰的短期缓存。若等待完整发布,则打开旧历史会额外要求写权限并等待发布。实现前必须确定这一行为,不以一个模糊的 done 混用两种里程碑。 + +## 考虑过的替代方案 + +**通过全局 control 推送迁移进度。** 单次打开历史的等待、错误与重试更适合由该读取流程拥有,不需要增加独立的全局进度状态同步。 + +**直接在普通 history journal 首帧前插入进度。** 通用 journal 保持 snapshot-first;Session 适配层消费重定向和迁移流,只把正常历史交给 journal。 + +**所有 RPC 遇到迁移都自动重发。** 已执行副作用的命令不能安全重放。此提案限定可安全恢复的历史读取,不扩展成通用命令重试平台。 + +## 验收条件 + +- 迁移等待开始后,Client 在收到历史 snapshot 前显示真实阶段;失败保持可见且不会自动循环重试。 +- 并发加入、单等待者取消和重连均取得同一操作的当前状态;旧尝试的通知不覆盖新尝试。 +- 完成后的历史读取不会因为缓存淘汰重新启动同一迁移;已发布 generation 不因取消而回滚。 +- 未知总量不显示伪造百分比。迁移时无关轻量请求和进度帧仍能得到执行机会,响应性单独测量。 +- create/search、fork/catalog 策略及已有 Session 数据保持各自约定。 + +## 风险 + +重定向跨越两次请求,需要明确保活与取消所有权。迁移进度并不会消除 Host 主线程的同步解析和验证暂停;相关调度调整必须依据实测。只读准备与写入发布的完成定义仍是实施前置选择。 diff --git a/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.i18n.yaml b/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.i18n.yaml new file mode 100644 index 0000000000..4aad229581 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for 2026-10-02-plugin-market-index-format.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.md +/agent-note-plugin-market-index-format: + en: 8da20a8601d7cb29 + zh: 74368eed4d4b8e7c +/agent-note-plugin-market-index-format/problem: + en: ce5e4a362f558af6 + zh: 29d93e0655b99217 +/agent-note-plugin-market-index-format/proposal: + en: ed9148e95b2ad7c2 + zh: 25a7add3c56fd681 +/agent-note-plugin-market-index-format/proposal/example: + en: 1b1c960c966cf912 + zh: 97be87cb14893c26 +/agent-note-plugin-market-index-format/proposal/root-fields: + en: 4fac5a9b8261342d + zh: f9168a8a4209feab +/agent-note-plugin-market-index-format/proposal/plugin-fields: + en: 19d743bd04dc65b3 + zh: 810a7f98c4fe8301 +/agent-note-plugin-market-index-format/proposal/version-fields: + en: d6c0fa99e1cbb068 + zh: 3d2a7b85299c679a +/agent-note-plugin-market-index-format/proposal/install-arguments: + en: c748cdc4d8ce349a + zh: 5740fa0d6106e637 +/agent-note-plugin-market-index-format/proposal/dsh-compatibility: + en: 00fe507b9b5dfea7 + zh: 55de6aaeb3865e84 +/agent-note-plugin-market-index-format/proposal/generator-obligations: + en: 8e848a102fe9d86e + zh: feff226ef8321e00 +/agent-note-plugin-market-index-format/proposal/reader-rules: + en: 92903cbe87d9c0ca + zh: 54af6cb33e9da252 +/agent-note-plugin-market-index-format/alternatives-considered: + en: 3d7cf519bc8e0593 + zh: 328dc0747836eaa8 +/agent-note-plugin-market-index-format/acceptance-criteria: + en: 753abee6adf39812 + zh: 1102bcfdd48f7e90 +/agent-note-plugin-market-index-format/risks: + en: 41170d483b17df80 + zh: 1bfd8f44e0f09036 diff --git a/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.md b/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.md new file mode 100644 index 0000000000..6ab05ec875 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.md @@ -0,0 +1,234 @@ +# Agent Note: Plugin market index format + +Status: proposed + +English | [中文](2026-10-02-plugin-market-index-format.zh.md) + +## Problem + +DSH installs third-party bundles only from a spec the user already knows: an npm name, a Git URL, a tarball, or a local path. Nothing lets a user browse installable bundles. No official market exists, and one may be added later, so a market mechanism must work with indexes that third parties publish. + +The smallest possible index is a list of npm names or Git repository URLs, one per line. It identifies every plugin, but a client rendering it must fetch each package's manifest, locale files, and icon before it can show a list. A list page that issues one or more requests per row is slow, fails partially, and puts metadata resolution in every client. + +An index must therefore carry everything a list page displays: localized title and description, icon, author, available versions, compatibility, and publish time. Markets also want to publish their own data, such as categories, tags, downloads, and ratings, without DSH knowing what those values mean. + +## Proposal + +Separate the **index** from the **index generator**. DSH defines and reads one JSON index format. How an index is produced, and from which source list, belongs to the generator. DSH's reader, its Discover page, and a reference generator are separate changes that consume this format. Indexes list [profile plugin bundles](../../implemented/architecture/2026-08-05-profile-plugin-bundles.md) and install them through [guided plugin installation](../../implemented/architecture/2026-09-15-guided-plugin-installation.md). + +The market reuses npm's package names, versions, ranges, distribution tags, and person declarations. Readers and generators use maintained npm parsers and validators. Additional fields or changed meanings require a plugin-market requirement that npm does not meet. + +### Example + +```json +{ + "format": 1, + "title": { "en": "Example Market", "zh": "示例市场" }, + "description": { "en": "Community DSH bundles", "zh": "社区 DSH bundle" }, + "metadata": { + "categories": { + "productivity": { "title": { "en": "Productivity", "zh": "效率" } } + }, + "labels": { + "official": { "title": { "en": "Official", "zh": "官方" } } + }, + "sortKeys": { + "downloads": { "title": { "en": "Downloads", "zh": "下载量" }, "order": "desc" }, + "rating": { "title": { "en": "Rating", "zh": "评分" }, "order": "desc" } + } + }, + "plugins": [ + { + "name": "@example/dsh-auto-review", + "title": { "en": "Auto Review", "zh": "自动审查" }, + "description": { "en": "Per-tool LLM authorization review", "zh": "逐工具 LLM 授权审查" }, + "icon": "icons/auto-review.svg", + "author": "Example Labs", + "homepage": "https://example.com/dsh-auto-review", + "metadata": { + "category": "productivity", + "labels": ["official"], + "keywords": ["review", "authorization"], + "sortKeys": { "downloads": 12034, "rating": 4.6 } + }, + "dist-tags": { "latest": "0.2.0-rc.1", "next": "0.2.0-rc.2" }, + "versions": [ + { + "version": "0.2.0-rc.2", + "publishTimestamp": 1790476800, + "source": { "type": "npm" }, + "engines": { "dsh": ">=0.2.0 <0.3.0" } + }, + { + "version": "0.2.0-rc.1", + "publishTimestamp": 1789872000, + "source": { "type": "npm" }, + "engines": { "dsh": "0.2.0 - 0.2.4 || >=0.3.0 <0.4.0" } + } + ] + }, + { + "name": "dsh-plugin-foo", + "title": "Foo", + "icon": "https://example.com/foo.png", + "dist-tags": { "latest": "1.0.0" }, + "versions": [ + { + "version": "1.0.0", + "publishTimestamp": 1788000000, + "source": { + "type": "git", + "url": "https://github.com/author/dsh-plugins", + "commit": "3f9c2e1d8a4b6c0e7f1a2b3c4d5e6f708192a3b4", + "path": "packages/foo" + }, + "engines": { "dsh": "*" } + } + ] + } + ] +} +``` + +`LocalizedText` below is the `LocalizedText` type of [`dsh-package-manifest`](../../../../packages/util/package-manifest/src/types.ts), a string or an object of strings that contains `en`, with two additional rules: object keys are lowercase language ids matching `^[a-z]{2,8}(-[a-z0-9]{1,8})*$`, and every string contains at least one non-whitespace character. + +### Root fields + +| Field | Required | Rule | +|---|---|---| +| `format` | yes | Integer `1`. | +| `title` | yes | `LocalizedText` naming the market. | +| `description` | no | `LocalizedText`. | +| `metadata` | no | Object holding the three declaration maps below. | +| `metadata.categories` | no | Object mapping category id → `{ title: LocalizedText }`. | +| `metadata.labels` | no | Object mapping label id → `{ title: LocalizedText }`. | +| `metadata.sortKeys` | no | Object mapping sort-key id → `{ title: LocalizedText, order: "asc" \| "desc" }`; `order` is the initial sort direction. | +| `plugins` | yes | Array of plugins, possibly empty; its order is the index's default ordering. | + +Category, label, and sort-key ids are non-empty strings. Readers look ids up as own properties, so ids such as `constructor` have no special meaning. + +### Plugin fields + +| Field | Required | Rule | +|---|---|---| +| `name` | yes | Package name accepted by npm's maintained package-name validator for new packages; unique within the index. | +| `title` | no | `LocalizedText`; a reader displays `name` when absent. | +| `description` | no | `LocalizedText`. | +| `icon` | no | Either `data:;base64,`, where `` is `image/svg+xml`, `image/png`, `image/jpeg`, or `image/webp`, `` is canonically padded base64, and the decoded payload is at most 256 KiB; or a URL reference whose resolution against the index URL is an `https:` URL or has the index URL's origin. | +| `author` | no | String with at least one non-whitespace character. | +| `homepage` | no | Absolute `https:` or `http:` URL of a page about the plugin. | +| `metadata` | no | Object holding the values below. | +| `metadata.category` | no | One declared category id. | +| `metadata.labels` | no | Array of distinct declared label ids. | +| `metadata.keywords` | no | Array of free-form strings; no root declaration required. | +| `metadata.sortKeys` | no | Object mapping a declared sort-key id → finite number. | +| `dist-tags` | no | Object mapping npm distribution-tag names to exact listed version strings; absent means no recorded channels. | +| `versions` | yes | Non-empty array of versions; see below. Array order does not select the installation version. | + +`metadata.keywords` conventionally comes from npm `package.json` `keywords`, but generators may use another source, add strings, or adjust values. Neither presence nor equality with the package manifest is required. Readers preserve the strings and their order; an empty array is valid. + +### Version fields + +| Field | Required | Rule | +|---|---|---| +| `version` | yes | Exact version parsed by npm's `node-semver`; preserve the complete string, including Git build metadata. The exact string is unique within the plugin. | +| `publishTimestamp` | yes | Integer Unix timestamp in seconds, in `[0, 10^11)`. | +| `source` | yes | Where `version` is installed from; see below. | +| `engines` | yes | Object containing `dsh`. | +| `engines.dsh` | yes | Non-blank valid npm SemVer range declared in the source manifest; copied unchanged. `*` explicitly declares support for any DSH version. | + +A relative `icon` reference resolves against the index URL, as an HTML image reference resolves against its page. For an index at `https://market.example/dsh/index.json`, `"icon": "icons/foo.svg"` names `https://market.example/dsh/icons/foo.svg`. A generator can therefore publish the index and its icon files as one directory on any static host without knowing the final URL. Absolute icon URLs on other origins must use `https:`; the same-origin allowance lets an `http:` index on a loopback or intranet host serve its own icons. Readers resolve the reference when they read the index, so clients receive absolute URLs. + +`source` takes one of two forms, and readers reject any other `type`: + +- `{ "type": "npm", "registry"?: string }`. `registry` is an `http:` or `https:` URL without credentials, query, or fragment; `http:` is admitted for intranet mirrors, as in DSH's [install registries](../../implemented/architecture/2026-09-18-plugin-install-registries.md). +- `{ "type": "git", "url": string, "commit": string, "path"?: string }`. `url` is a canonical `https:` URL (equal to its WHATWG URL serialization) with at least two non-empty path segments and no trailing slash, without credentials, query, or fragment, and not ending in `.tgz` or `.tar.gz`. `commit` is a lowercase 40-hex-digit SHA. `path` is a `/`-separated relative directory whose segments contain only ASCII letters, digits, and `._@+-`, and are neither `.` nor `..`; absent means the repository root. + +### Install arguments + +A client installs one version with exactly these pnpm arguments: + +- npm: the spec `@`. Without `registry`, the client asks its configured registries as it does for any install. With `registry`, the client follows DSH's [registry plan](../../implemented/architecture/2026-09-18-plugin-install-registries.md): it asks `registry` first and falls back to its other configured registries only when `registry` is one of them; any other `registry` is asked alone, so a private registry never falls through to a public one that may publish a different package under the same name and version. +- git: the spec `git+.git#`, without doubling a trailing `.git`, followed by `&path:/` when `path` is present. The `git+` prefix makes pnpm treat URLs with more than two path segments, such as GitLab subgroups, as Git repositories. + +### DSH compatibility + +Every market-listed version must declare valid `engines.dsh`. The reader and market-origin installation evaluate that range against the running DSH version with npm's `node-semver` and `includePrerelease: true`. There is no peer fallback or range synthesis. npm package peer resolution remains outside market host compatibility. + +Installation rechecks the source manifest before accepting a market-origin package. An exact local `name@version` exemption may permit a valid range that rejects the runtime; it cannot make a missing or invalid declaration eligible. Exemptions are not published in the index. + +### Generator obligations + +A reader cannot verify these rules; a generator that violates them publishes a misleading index. + +- `name` and each `version` equal the package manifest of the installed source. +- `publishTimestamp` is the registry publish time of `version` for npm sources and the committer time of `commit` for Git sources. +- `engines.dsh` copies the manifest's declared range unchanged. A version with a missing, blank, or invalid range is omitted with a diagnostic. A plugin with no eligible version is omitted. +- `dist-tags` snapshots npm's [distribution tags](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/), retaining only targets present among the eligible listed versions. For Git sources, the publisher or market supplies the same exact tag-to-version mapping. +- Each listed version declares `dsh.bundle`. Installation independently refuses a package without a bundle patch. +- `title`, `description`, and `icon` come from the highest listed version's locale metadata, manifest fields, and icon under the installed-plugin display rules. Use npm's `node-semver.rsort()` order, retaining input order for ties. This metadata choice does not select the installation version. `homepage` copies that version's manifest `homepage` and is absent without one. +- `author` comes from the same highest listed version's npm author declaration. Copy an object's `name`; parse shorthand strings with a maintained npm person normalizer. Omit an absent or whitespace-only name and contact fields. Normalize a fresh projection, leaving the source manifest untouched. + +### Reader rules + +A reader rejects the whole index when the document violates any rule in Root fields, Plugin fields, Version fields, or the `source` forms, including an undeclared category, label, or sort-key reference, an unlisted distribution-tag target, a duplicate `name` or exact `version` string, or a `format` other than `1`. Each such defect is a generator bug, and dropping entries silently would hide it. Readers may bound the document size. + +A reader ignores members that this note does not define, at every level; an unknown `source.type` value is rejected. Format 1 can then gain optional members without breaking existing readers; a change that existing readers would misinterpret uses a new `format` value. + +Tag names must parse as registry tag specs under npm's maintained `npm-package-arg`; targets are exact listed version strings. npm sources use canonical published identifiers (`node-semver.valid(version) === version`); Git sources retain build metadata and pin their exact source by commit. + +Categories, labels, and sort keys are opaque: a reader filters and sorts by them, displays their `title`, and never interprets an id. Readers display declarations in the order of their localized `title`. When sorting plugins, a plugin without a value for the selected sort key follows all plugins with a value in either direction, and equal values keep the index order. Sorting by publish time uses a plugin's greatest `publishTimestamp`. + +A reader evaluates the required `engines.dsh` range under DSH compatibility. Versions use npm's `node-semver.rsort()` order, retaining index order for ties. Exact tag targets, not this display order, select installation versions. + +The initial channel is `latest`. Its exact target is the publisher- or market-selected version. Clients install that recorded version or commit, never `name@latest`. Missing or incompatible `latest` requires explicit version or channel selection; clients do not switch automatically. Installation confirmation identifies the version, selected channel if any, prerelease status, index URL, and source. + +## Alternatives considered + +**A plain list of npm names or repository URLs.** It identifies every plugin but forces one or more requests per row before a list renders, which is the cost this format removes. + +**One opaque install spec string per version.** A reader cannot verify that a free-form spec is pinned. A structured `source` with an exact `version`, or a `commit`, lets the reader verify pinning and lets the client build the spec it installs. + +**Installing the latest version instead of a pinned one.** The installed build could then differ from the metadata the list displayed, and a package release would change what an unchanged index installs. + +**One entry per version.** Every version would repeat the title, description, icon, author, and market metadata, and readers would group entries by `name` to show one plugin. + +**One `source` per plugin, with only the pin per version.** A plugin that moves to another repository, directory, or registry between releases could not list its older versions. + +**Display fields per version.** A list shows one row per plugin, so older display text, icons, authors, and homepages would cost index size without a reader that shows them. + +**Inline icons only.** A list without extra requests needs inline icons, but 256 KiB of raw bytes is about 341 KiB as base64, so a 100-plugin index could reach about 35 MB. URL icons trade extra requests, and disclosure of the user's IP address to the image host, for a small index. + +**Inferring host compatibility from peer dependencies.** The market defines a new standard requiring an author-declared `engines.dsh`. Existing packages must add that declaration before listing; no fallback, workspace mapping, or range intersection is needed. + +**Copying the complete npm person object into the display field.** The list needs a name. The generator supports npm's original declaration while omitting unused contact fields. + +**Opaque sort values for time, including ISO date strings.** Publish time has fixed semantics that clients format and sort, so it is a typed field. Without time, sort values can be numbers only, which needs no per-key value-type rule. + +**Root-level `labels` and `sortKeys`, and a plugin field named `sort`.** Grouping market-defined declarations and values under `metadata` at both levels, with matching member names except the single-valued `category`, separates them from fields that clients interpret. + +**Declaration order as display order.** JavaScript reorders integer-like object keys, so declaration order would need either an id pattern or array declarations. Clients sort declarations by their localized title instead. + +**Selecting the greatest compatible version.** npm's `latest` can name an older release. Inferring a target from SemVer ranking loses the recorded channel choice and is ambiguous for equally ranked builds. + +**Automatic prerelease fallback.** Missing or incompatible channel targets do not mean that the user selected a prerelease. + +**Rejecting unknown members.** Every optional addition would then require a new `format` value and break every deployed reader. + +## Acceptance criteria + +- A validator accepts the example above and rejects one violation of each rule in Root fields, Plugin fields, Version fields, and the `source` forms, with a diagnostic naming the JSON path. +- Install arguments built from every example version are accepted by DSH's install-spec parser and by pnpm. +- A validator ignores unknown members at every level. +- Generator and validator refuse missing, blank, and invalid `engines.dsh`; omitted versions leave no dangling tag target. Index and market-origin installation agree on valid ranges, including hyphen ranges, prereleases, and valid ranges that reject the runtime. Exemptions never bypass the declaration requirement. +- Exact tag targets select the same source after equally ranked Git build versions are reordered. Missing targets are rejected; absent or incompatible `latest` causes no automatic switch. +- npm author objects and shorthand strings produce the declared name without contact fields or mutation of source identity. + +## Risks + +- An index is unsigned. Titles, authors, labels, and scores are market claims; an `official` label is not DSH verification. Clients must show the index URL and each version's `source`. +- A Git version's `name`, `version`, and `engines.dsh` are claims about the repository. A repository can claim any name, including an `@deepseek-ai/dsh-` name, and the installed manifest decides what is actually installed. +- An npm version's `source.registry` is equally a claim: it directs the install of any `name`, including an `@deepseek-ai/dsh-` name, to a registry the market chooses, and that registry decides what is installed. +- URL icons disclose the user's IP address to image hosts. +- Pinned versions and distribution tags stay stale until the index is regenerated. +- Market text reaches users, and models through any client tool that lists plugins, without review by DSH. diff --git a/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.zh.md b/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.zh.md new file mode 100644 index 0000000000..12608c767b --- /dev/null +++ b/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.zh.md @@ -0,0 +1,234 @@ +# Agent Note: 插件市场索引格式 + +Status: proposed + +[English](2026-10-02-plugin-market-index-format.md) | 中文 + +## 问题 + +DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包名、Git URL、tarball 或本地路径。用户无法浏览可安装的组合包。目前没有官方市场,以后可能会增加官方市场,因此市场机制必须支持第三方发布的索引。 + +最小的索引是一份 npm 包名或 Git 仓库 URL 列表,每行一项。它能标识每个插件,但客户端渲染列表前必须为每个包获取 manifest(元数据清单)、语言文件和图标。列表页面每行发出一个或多个请求时,加载慢、可能部分失败,并且每个客户端都要实现元数据解析。 + +因此,索引必须包含列表页面显示的全部内容:本地化标题和描述、图标、作者、可用版本、兼容性和发布时间。市场还需要发布自己的数据,例如分类、标签、下载量和评分,而 DSH 不需要理解这些值的含义。 + +## 提案 + +将**索引**与**索引生成器**分开。DSH 定义并读取一种 JSON 索引格式。索引如何生成、基于哪份源列表生成,由生成器负责。DSH 的读取器、Discover 页面和参考生成器是使用此格式的独立变更。索引列出[profile 插件组合包](../../implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md),并通过[引导式插件安装](../../implemented/architecture/2026-09-15-guided-plugin-installation.zh.md)安装它们。 + +市场复用 npm 的包名、版本、范围、发布标签和 person 声明。读取器与生成器使用维护中的 npm 解析器和校验器。新增字段或改变含义需要 npm 无法满足的插件市场需求。 + +### 示例 + +```json +{ + "format": 1, + "title": { "en": "Example Market", "zh": "示例市场" }, + "description": { "en": "Community DSH bundles", "zh": "社区 DSH bundle" }, + "metadata": { + "categories": { + "productivity": { "title": { "en": "Productivity", "zh": "效率" } } + }, + "labels": { + "official": { "title": { "en": "Official", "zh": "官方" } } + }, + "sortKeys": { + "downloads": { "title": { "en": "Downloads", "zh": "下载量" }, "order": "desc" }, + "rating": { "title": { "en": "Rating", "zh": "评分" }, "order": "desc" } + } + }, + "plugins": [ + { + "name": "@example/dsh-auto-review", + "title": { "en": "Auto Review", "zh": "自动审查" }, + "description": { "en": "Per-tool LLM authorization review", "zh": "逐工具 LLM 授权审查" }, + "icon": "icons/auto-review.svg", + "author": "Example Labs", + "homepage": "https://example.com/dsh-auto-review", + "metadata": { + "category": "productivity", + "labels": ["official"], + "keywords": ["review", "authorization"], + "sortKeys": { "downloads": 12034, "rating": 4.6 } + }, + "dist-tags": { "latest": "0.2.0-rc.1", "next": "0.2.0-rc.2" }, + "versions": [ + { + "version": "0.2.0-rc.2", + "publishTimestamp": 1790476800, + "source": { "type": "npm" }, + "engines": { "dsh": ">=0.2.0 <0.3.0" } + }, + { + "version": "0.2.0-rc.1", + "publishTimestamp": 1789872000, + "source": { "type": "npm" }, + "engines": { "dsh": "0.2.0 - 0.2.4 || >=0.3.0 <0.4.0" } + } + ] + }, + { + "name": "dsh-plugin-foo", + "title": "Foo", + "icon": "https://example.com/foo.png", + "dist-tags": { "latest": "1.0.0" }, + "versions": [ + { + "version": "1.0.0", + "publishTimestamp": 1788000000, + "source": { + "type": "git", + "url": "https://github.com/author/dsh-plugins", + "commit": "3f9c2e1d8a4b6c0e7f1a2b3c4d5e6f708192a3b4", + "path": "packages/foo" + }, + "engines": { "dsh": "*" } + } + ] + } + ] +} +``` + +下文中的 `LocalizedText` 即 [`dsh-package-manifest`](../../../../packages/util/package-manifest/src/types.ts) 中的 `LocalizedText` 类型,即字符串或包含 `en` 的字符串对象,并附加两条规则:对象的键是匹配 `^[a-z]{2,8}(-[a-z0-9]{1,8})*$` 的小写语言 id,每个字符串至少包含一个非空白字符。 + +### 根字段 + +| 字段 | 必填 | 规则 | +|---|---|---| +| `format` | 是 | 整数 `1`。 | +| `title` | 是 | 市场名称,类型为 `LocalizedText`。 | +| `description` | 否 | `LocalizedText`。 | +| `metadata` | 否 | 包含下列三个声明映射的对象。 | +| `metadata.categories` | 否 | 对象,将分类 id 映射为 `{ title: LocalizedText }`。 | +| `metadata.labels` | 否 | 对象,将标签 id 映射为 `{ title: LocalizedText }`。 | +| `metadata.sortKeys` | 否 | 对象,将排序键 id 映射为 `{ title: LocalizedText, order: "asc" \| "desc" }`;`order` 是初始排序方向。 | +| `plugins` | 是 | 插件数组,可以为空;数组顺序即索引的默认排序。 | + +分类、标签和排序键的 id 均为非空字符串。读取器按自有属性查找 id,因此 `constructor` 等 id 没有特殊含义。 + +### 插件字段 + +| 字段 | 必填 | 规则 | +|---|---|---| +| `name` | 是 | npm 维护的包名校验器接受的新包名称;在索引内唯一。 | +| `title` | 否 | `LocalizedText`;缺省时读取器显示 `name`。 | +| `description` | 否 | `LocalizedText`。 | +| `icon` | 否 | 以下两种形式之一:`data:;base64,`,其中 `` 为 `image/svg+xml`、`image/png`、`image/jpeg` 或 `image/webp`,`` 是按规范填充的 base64,解码后的载荷不超过 256 KiB;或者 URL 引用,它相对于索引 URL 解析的结果是 `https:` URL,或与索引 URL 同源。 | +| `author` | 否 | 至少包含一个非空白字符的字符串。 | +| `homepage` | 否 | 介绍该插件的页面的绝对 `https:` 或 `http:` URL。 | +| `metadata` | 否 | 包含下列值的对象。 | +| `metadata.category` | 否 | 一个已声明的分类 id。 | +| `metadata.labels` | 否 | 由互不相同的已声明标签 id 组成的数组。 | +| `metadata.keywords` | 否 | 自由字符串数组;无需在根部声明。 | +| `metadata.sortKeys` | 否 | 对象,将已声明的排序键 id 映射为有限数值。 | +| `dist-tags` | 否 | 将 npm 发布标签名称映射为已列出的精确版本字符串的对象;缺省表示未记录发布渠道。 | +| `versions` | 是 | 非空的版本数组;见下文。数组顺序不选择安装版本。 | + +`metadata.keywords` 按约定来自 npm `package.json` 的 `keywords`,但生成器可以使用其他来源、添加字符串或调整值。不要求提供此字段,也不要求它与包 manifest 一致。读取器保留字符串及其顺序;允许空数组。 + +### 版本字段 + +| 字段 | 必填 | 规则 | +|---|---|---| +| `version` | 是 | npm 的 `node-semver` 解析的精确版本;保留完整字符串,包括 Git 版本编译信息。精确字符串在插件内唯一。 | +| `publishTimestamp` | 是 | 以秒为单位的整数 Unix 时间戳,取值范围为 `[0, 10^11)`。 | +| `source` | 是 | `version` 的安装来源;见下文。 | +| `engines` | 是 | 包含 `dsh` 的对象。 | +| `engines.dsh` | 是 | 源 manifest 声明的非空白、有效 npm SemVer 范围;原样复制。`*` 显式声明支持任意 DSH 版本。 | + +相对的 `icon` 引用相对于索引 URL 解析,与 HTML 图片引用相对于所在页面解析的方式相同。对于位于 `https://market.example/dsh/index.json` 的索引,`"icon": "icons/foo.svg"` 指向 `https://market.example/dsh/icons/foo.svg`。因此,生成器可以把索引和图标文件作为一个目录发布到任意静态主机,无需知道最终 URL。指向其他源的绝对图标 URL 必须使用 `https:`;同源规则允许位于 loopback 或内网主机上的 `http:` 索引提供自己的图标。读取器在读取索引时解析该引用,因此客户端收到的是绝对 URL。 + +`source` 采用以下两种形式之一,读取器拒绝其他任何 `type`: + +- `{ "type": "npm", "registry"?: string }`。`registry` 是不含凭据、查询或片段的 `http:` 或 `https:` URL;允许 `http:` 是为了支持内网镜像,与 DSH 的[安装 registry](../../implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 一致。 +- `{ "type": "git", "url": string, "commit": string, "path"?: string }`。`url` 是规范的 `https:` URL(等于其 WHATWG URL 序列化结果),至少包含两个非空路径段且不以斜杠结尾,不含凭据、查询或片段,且不以 `.tgz` 或 `.tar.gz` 结尾。`commit` 是 40 位小写十六进制 SHA。`path` 是以 `/` 分隔的相对目录,各段只包含 ASCII 字母、数字和 `._@+-`,且不是 `.` 或 `..`;缺省表示仓库根目录。 + +### 安装参数 + +客户端安装某个版本时恰好使用以下 pnpm 参数: + +- npm:spec 为 `@`。没有 `registry` 时,客户端像任何安装一样询问它配置的 registry。存在 `registry` 时,客户端遵循 DSH 的 [registry 计划](../../implemented/architecture/2026-09-18-plugin-install-registries.zh.md):先询问 `registry`,仅当它属于客户端配置的 registry 之一时才回退到其他已配置 registry;其他 `registry` 单独询问,因此私有 registry 不会回退到可能以相同名称和版本发布不同包的公共 registry。 +- git:spec 为 `git+.git#`,`url` 已以 `.git` 结尾时不重复添加;存在 `path` 时在其后追加 `&path:/`。`git+` 前缀使 pnpm 将路径段多于两个的 URL(例如 GitLab 子组)视为 Git 仓库。 + +### DSH 兼容性 + +市场列出的每个版本都必须声明有效的 `engines.dsh`。读取器与市场来源的安装使用 npm 的 `node-semver` 和 `includePrerelease: true`,根据当前运行的 DSH 版本求值。不存在对等依赖回退或范围合成。npm 包的对等依赖解析不属于市场宿主兼容性检查。 + +安装接受市场来源的包前,会重新检查源 manifest。精确的本地 `name@version` 豁免可以允许有效范围拒绝当前运行时的情况,但不能使缺失或无效声明符合列出条件。豁免不发布在索引中。 + +### 生成器义务 + +读取器无法验证以下规则;违反这些规则的生成器会发布误导性的索引。 + +- `name` 和每个 `version` 与所安装来源中的包 manifest 一致。 +- 对于 npm 来源,`publishTimestamp` 是 `version` 在 registry 中的发布时间;对于 Git 来源,它是 `commit` 的提交者时间。 +- `engines.dsh` 原样复制 manifest 声明的范围。缺失、空白或无效范围的版本会被省略并给出诊断。没有合格版本的插件会被省略。 +- `dist-tags` 是 npm [发布标签](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/)的快照,只保留指向合格且已列出版本的目标。对于 Git 来源,发布者或市场提供同样的精确标签到版本映射。 +- 列出的每个版本都声明了 `dsh.bundle`。安装时也会独立拒绝没有组合包 patch 的包。 +- `title`、`description` 和 `icon` 来自所列最高版本的语言元数据、manifest 字段和图标,采用已安装插件的显示规则。使用 npm 的 `node-semver.rsort()` 顺序,相等时保留输入顺序。元数据选择不选择安装版本。`homepage` 复制该版本 manifest 的 `homepage`,manifest 没有该字段时省略。 +- `author` 来自同一最高版本的 npm 作者声明。复制对象的 `name`;使用维护中的 npm person 归一化器解析简写字符串。省略缺失或全空白的名称以及联系字段。归一化新建的投影,不修改源 manifest。 + +### 读取器规则 + +文档违反根字段、插件字段、版本字段或 `source` 形式中的任何规则时,读取器拒绝整个索引,包括引用了未声明的分类、标签或排序键,发布标签指向未列出的目标,`name` 或精确的 `version` 字符串重复,以及 `format` 不是 `1`。每个此类缺陷都是生成器 bug,静默丢弃条目会掩盖它。读取器可以限制文档大小。 + +读取器在每一层都忽略本 Agent Note 未定义的成员;未知的 `source.type` 值会被拒绝。这样格式 1 可以增加可选成员而不破坏现有读取器;现有读取器会误解的变更使用新的 `format` 值。 + +标签名称必须能被 npm 维护的 `npm-package-arg` 解析为 registry 标签 spec;目标是已列出的精确版本字符串。npm 来源使用规范发布标识(`node-semver.valid(version) === version`);Git 来源保留版本编译信息并用提交固定精确来源。 + +分类、标签和排序键对读取器不透明:读取器按它们筛选和排序,显示其 `title`,从不解释 id 的含义。读取器按本地化 `title` 对声明排序后显示。对插件排序时,无论排序方向如何,没有所选排序键值的插件都排在所有有值的插件之后,值相同的插件保持索引顺序。按发布时间排序时使用插件最大的 `publishTimestamp`。 + +读取器按 DSH 兼容性一节的规则求值必填的 `engines.dsh` 范围。版本使用 npm 的 `node-semver.rsort()` 顺序,相等时保留索引顺序。安装版本由精确的标签目标选择,而不是此显示顺序。 + +初始渠道是 `latest`。它的精确目标是发布者或市场选择的版本。客户端安装记录的版本或提交,绝不安装 `name@latest`。缺失或不兼容的 `latest` 需要显式选择版本或渠道;客户端不会自动切换。安装确认标明版本、所选渠道(如果有)、先行版本状态、索引 URL 和来源。 + +## 考虑过的替代方案 + +**纯 npm 包名或仓库 URL 列表。** 它能标识每个插件,但列表渲染前每行需要一个或多个请求,而此格式正是为消除这一开销而设计。 + +**每个版本使用一个不透明的安装 spec 字符串。** 读取器无法验证自由格式的 spec 是否固定了版本。结构化的 `source` 配合精确的 `version` 或 `commit`,读取器可以验证版本固定,客户端可以构建要安装的 spec。 + +**安装最新版本而不是固定版本。** 这样安装的构建可能与列表显示的元数据不一致,并且包发布新版本会改变未修改的索引所安装的内容。 + +**每个版本一个条目。** 每个版本都会重复标题、描述、图标、作者和市场元数据,读取器还要按 `name` 分组才能显示一个插件。 + +**每个插件一个 `source`,每个版本只记录固定点。** 在两次发布之间换了仓库、目录或 registry 的插件将无法列出旧版本。 + +**每个版本各有显示字段。** 列表每个插件只显示一行,旧的显示文本、图标、作者和主页会增加索引大小,却没有读取器显示它们。 + +**只允许内联图标。** 不发额外请求的列表需要内联图标,但 256 KiB 原始字节编码为 base64 后约为 341 KiB,因此 100 个插件的索引可能达到约 35 MB。URL 图标以额外请求和向图片主机暴露用户 IP 地址为代价,换取较小的索引。 + +**从对等依赖推断宿主兼容性。** 市场定义要求作者声明 `engines.dsh` 的新标准。现有包必须添加此声明才能列出;不需要回退、workspace 映射或范围求交。 + +**将完整 npm person 对象复制到显示字段。** 列表需要名称。生成器支持 npm 的原始声明,同时省略不用的联系字段。 + +**用不透明排序值表示时间,包括 ISO 日期字符串。** 发布时间具有固定语义,客户端会对其格式化和排序,因此它是一个有类型的字段。排除时间后,排序值可以只用数值,无需为每个键规定值类型。 + +**根级 `labels` 和 `sortKeys`,以及名为 `sort` 的插件字段。** 在两个层级都把市场定义的声明和值归入 `metadata`,并使用对应的成员名(单值的 `category` 除外),可以将它们与客户端解释的字段区分开。 + +**以声明顺序作为显示顺序。** JavaScript 会重排类整数的对象键,因此要保留声明顺序,就需要限定 id 格式或改用数组声明。客户端改为按本地化标题对声明排序。 + +**选择最高兼容版本。** npm 的 `latest` 可以指向较旧的发布版本。从 SemVer 排名推断目标会丢失记录的渠道选择,而且在构建排名相等时存在歧义。 + +**自动回退到先行版本。** 渠道目标缺失或不兼容,不表示用户选择了先行版本。 + +**拒绝未知成员。** 这样每次增加可选成员都需要新的 `format` 值,并会破坏所有已部署的读取器。 + +## 验收标准 + +- 校验器接受上面的示例,并对根字段、插件字段、版本字段和 `source` 形式中的每条规则各拒绝一个违例,诊断信息给出对应的 JSON 路径。 +- 根据每个示例版本构建的安装参数可被 DSH 的安装 spec 解析器和 pnpm 接受。 +- 校验器在每一层都忽略未知成员。 +- 生成器和校验器拒绝缺失、空白和无效的 `engines.dsh`;省略版本后不留下悬空的标签目标。索引和市场来源的安装对有效范围结果一致,包括连字符范围、先行版本以及拒绝当前运行时的有效范围。豁免绝不绕过声明要求。 +- 排名相等的 Git 构建版本重新排序后,精确标签目标仍选择同一来源。缺失的目标被拒绝;`latest` 缺省或不兼容时不自动切换。 +- npm 作者对象与简写字符串产生声明的名称,不包含联系字段,也不修改源身份。 + +## 风险 + +- 索引没有签名。标题、作者、标签和评分是市场的声明;`official` 标签不是 DSH 验证。客户端必须显示索引 URL 以及每个版本的 `source`。 +- Git 版本的 `name`、`version` 和 `engines.dsh` 是关于仓库的声明。仓库可以声明任意名称,包括 `@deepseek-ai/dsh-` 名称,实际安装的内容由已安装的 manifest 决定。 +- npm 版本的 `source.registry` 同样是一种声明:它把任意 `name`(包括 `@deepseek-ai/dsh-` 名称)的安装引向市场选择的 registry,由该 registry 决定实际安装的内容。 +- URL 图标会向图片主机暴露用户的 IP 地址。 +- 固定版本和发布标签在索引重新生成之前一直保持陈旧。 +- 市场文本未经 DSH 审核即到达用户,并通过任何列出插件的客户端工具到达模型。 diff --git a/.agents/skills/dsh-ci-test-reliability/SKILL.md b/.agents/skills/dsh-ci-test-reliability/SKILL.md index d9fd0f1700..ba97a37745 100644 --- a/.agents/skills/dsh-ci-test-reliability/SKILL.md +++ b/.agents/skills/dsh-ci-test-reliability/SKILL.md @@ -10,7 +10,7 @@ Build tests that remain correct under the repository's real CI topology, not onl ## Read the owning rules - Use [the testing policy](../../../docs/testing.md) to select unit, coverage, expected-output, snapshot, browser, or real-API evidence. -- Use [the defensive patterns](../../../docs/defensive-patterns.md) for lifecycle, subprocess, cancellation, and teardown behavior. +- Use [the defensive patterns](../../../docs/defensive-patterns/general.md) for lifecycle, subprocess, cancellation, and teardown behavior. - Read the active Vitest config and GitHub workflow when their worker or job topology affects the test. - For recorded-session scenarios, also follow [the snapshot instructions](../../../snapshots/AGENTS.md). - Use [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md) after the test design is sound to select outgoing validation. diff --git a/.agents/skills/dsh-client-ui-ux/SKILL.md b/.agents/skills/dsh-client-ui-ux/SKILL.md index de63792f72..b37a1ca4ba 100644 --- a/.agents/skills/dsh-client-ui-ux/SKILL.md +++ b/.agents/skills/dsh-client-ui-ux/SKILL.md @@ -7,6 +7,13 @@ description: Design and review DeepSeek Harness client UI changes — visual tok This skill is guidance, not a complete checklist. It covers judgment calls that lint, typecheck, and the i18n gate cannot make: where feedback appears, what gets reused, and how surfaces behave at window edges and across platforms. Copy quality itself follows [dsh-prose-standard](../dsh-prose-standard/SKILL.md); locale ownership follows the [locale-owned copy decision](../../notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md); styling ownership and token rules live in [docs/web-styling.md](../../../docs/web-styling.md) and the [ui-primitives component catalog](../../../packages/client/ui-primitives/README.md#component-catalog) — this skill adds judgment on top of those rules, not a second copy of them. +## Architecture prerequisites + +Component structure, state flow, and effect usage follow the architecture rules; read them before redesigning a component: + +- [packages/client/AGENTS.md](../../../packages/client/AGENTS.md) — Slot and props discipline, reactive read, export, ctx, and layering rules. +- [Client component rules](../../../docs/defensive-patterns/client-components.md) — those requirements stated with the defects they prevent. + ## Visual foundation - **Use existing tokens for color, radius, and spacing.** Before introducing a literal value, search the target page and its nearest established sibling (the Plugins page is the usual reference for full-page surfaces) for the token or value already in use, and adopt it. The same applies to whole treatments — toasts, menus, in-message artifact previews, sidebar tab layout, colors, clearance rules, page padding: reuse the established component or rule wherever one exists instead of inventing a parallel one. @@ -24,7 +31,7 @@ This skill is guidance, not a complete checklist. It covers judgment calls that ## Feedback surfaces -Choose the surface by the lifetime of the message relative to the surface that produced it: +Apply [dsh-error-handling](../dsh-error-handling/SKILL.md#localized-actionable-visible-reports) to failure reports. Choose the surface by the lifetime of the message relative to the surface that produced it: - **Transient operation outcomes use the app-wide Toast primitive**, not in-place notices, and the toast's state and rendering live in a host that outlives the reporting surface (a `shell.overlay` entry, as RowActionToast does) — a toast rendered by the panel itself unmounts with that panel. A deletion can close the panel or tab that requested it; only a toast held outside that surface survives the close. Report success and failure; do not report still-pending states. - **A failed operation keeps the data visible.** A failed deletion keeps its row; the toast announces the failure. Never blank content to show an error for a transient operation. diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 484cd1c288..ff242b4819 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -7,14 +7,22 @@ description: Use when reviewing a pull request in the deepseek-harness repo — **This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm --silent run change-scope --base --head ` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and broken required behavior over style; a short review with one substantiated blocker is better than a list of nits. +**Read the requirements before judging the diff.** Map the changed paths to the artifacts below, open each owning page from [Sources of truth](#sources-of-truth), and only then read the diff against those requirements. Judging a diff against memory of the rules is how a violation passes review. + ## Sources of truth - [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): standing repository and package authoring rules. -- [docs/defensive-patterns.md](../../../docs/defensive-patterns.md): subprocess, callback, async-state, and disposal bug classes. +- [docs/defensive-patterns/](../../../docs/defensive-patterns/README.md): the architecture requirements a change must satisfy and the habits behind them. Each page owns one artifact; read every page the diff touches: + - [general.md](../../../docs/defensive-patterns/general.md) — timing, async state, value representation, shared ownership, decentralization, refactors, migrations, performance evidence, disposal, callbacks, subprocesses. + - [package-surface.md](../../../docs/defensive-patterns/package-surface.md) — exports, package shape, build faces, dependency declarations, resolve hooks. + - [remote-and-api.md](../../../docs/defensive-patterns/remote-and-api.md) — remote services, `@Remote` methods, API dispatch, business ownership in `packages/api`. + - [client-components.md](../../../docs/defensive-patterns/client-components.md) — client components, slots, stores, hooks, effects, shared base packages. + - [conversation-and-session.md](../../../docs/defensive-patterns/conversation-and-session.md) — projection reads, Session retention, conversation nodes, renderers. - [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline. - [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. - [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md): isolation and regression-proof rules for resource-owning, asynchronous, or flaky tests and fixtures. - [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md): feedback-surface choice, overlay safety, platform window adaptation, and visual token discipline for product-user-visible GUI changes. +- [dsh-error-handling](../dsh-error-handling/SKILL.md): failure classification, handling ownership, containment, state guarantees, and actionable reports. - [docs/testing.md](../../../docs/testing.md) and the [quality-gates reference](../../../docs/testing.md): required test tiers and gates. - [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto. - For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation. @@ -31,7 +39,8 @@ description: Use when reviewing a pull request in the deepseek-harness repo — ## Manual checks - **Intent and interface contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. -- **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns.md](../../../docs/defensive-patterns.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. +- **Error handling:** apply [dsh-error-handling](../dsh-error-handling/SKILL.md) to changed failure paths, including their state guarantees and final user-visible reports. Reject catch-and-continue unless the state owner establishes that subsequent work's preconditions hold, including after recovery. +- **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns/general.md](../../../docs/defensive-patterns/general.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. - **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package rules](../../../packages/AGENTS.md). Flag the inverse too: a new public method on a generic service (registry, session, agent) whose only caller is one internal consumer is an unnecessary API expansion — require a private capability closure handed to that consumer at construction instead. - **Scope, ownership, and necessity:** map each abstraction, state machine, option, defensive copy, and compatibility path to its current contract, production consumer, and owning plugin or service. Challenge unrelated features and speculative generality, then test the PR against [the root rules](../../../AGENTS.md#conventions). - **Configuration and public choices:** ask what current-consumer evidence or prior art supports each default, public operation set, format, or imported external concept. Require an explicit choice or deferral when that evidence is absent. diff --git a/.agents/skills/dsh-doc/templates/persistence-change.md b/.agents/skills/dsh-doc/templates/persistence-change.md index 4d5e720254..f31867aaaf 100644 --- a/.agents/skills/dsh-doc/templates/persistence-change.md +++ b/.agents/skills/dsh-doc/templates/persistence-change.md @@ -13,7 +13,7 @@ kind: persistence-change ## Skeleton -The generator fills the machine declaration, schema companion, and consistency records, inferring the minimum version decision from the fixed rules. Supply `--prose` JSON with `en` and `zh` objects, each containing authored `summary`, `compatibility`, and `verification` strings; the [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) shows the complete operation. The following values illustrate the document fields; use the actual generated root names, record ids, and digests. +The generator fills the machine declaration, schema companion, and consistency records. Structural changes outside the established same-version cases require an explicit compatibility decision. Supply `--prose` JSON with `en` and `zh` objects, each containing authored `summary`, `compatibility`, and `verification` strings. Use Compatibility to explain both reader directions and Verification to record executed checks. The [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) shows the operation. Use the actual generated root names, record ids, and digests in the following fields. ````markdown # Persistence change: @@ -48,7 +48,7 @@ changes: ## Compatibility -Explain the declared decision for the detected paths. For an optional addition, explain why existing records can omit it and older readers can ignore it without changing replay. For required-to-optional changes, explain how readers handle absence. For a version bump, link the adjacent migration and its verification owner. +Explain the declared decision for every detected path: how older readers preserve meaning, safely ignore optional data, or refuse required features before execution or recovery; and how new readers preserve historical meaning and mixed representations. Feature absence retains existing behavior. For a version bump, link the adjacent migration and its verification owner. ## Verification @@ -66,8 +66,8 @@ None. - Keep exactly one `yaml persistence-change` block. The English and Chinese blocks are byte-identical; the verifier parses the English record once and checks its counterpart. - The filename stem and `id` agree. A root's `previous` identifies its predecessor record; `null` introduces a root. Its `after` matches the generated companion schema digest; `null` records deletion. - One initial `baseline: true` record covers the complete inventory. Routine changes use `baseline: false` and preserve accepted predecessors. A version-bump record carries its own increasing `SessionHeader.version` transition. -- Omit `--decision` in the ordinary authoring flow. An explicit value asserts the decision and cannot bypass classification or the required header increase. The command supplies no source changes or validation claims. +- Structural differences alone do not infer a bump. A same-version change requiring review needs explicit `--decision same-version`; a changed header version infers `version-bump`. An explicit decision cannot waive the required header increase. The command supplies no source changes or validation claims. - Explain only the detected persistence-type changes. This kind permits the before/after facts and historical evidence needed for that transition; behavior-only changes remain out of scope. - Record creation and terminal-record update generate the catalog pair and consistency sidecars. Review both languages and retain the generated companions. Direct prose edits still require re-recording pairing; do not hand-maintain schema nodes or digests. -- Use explicit `--update` only for an unaccepted terminal record. It rejects baselines and depended-on records; `--prose` replaces its authored explanations, while omission preserves them. Accepted records receive successors. +- Use explicit `--update` only for an unaccepted terminal record. It rejects baselines and depended-on records; `--prose` replaces its authored explanations, while omission preserves them. A flagged same-version update requires explicit `--decision same-version`; reassess the explanation against the complete transition. Accepted records receive successors. - The [folder reference](../../../../docs/persistence-changes/README.md) owns compatibility rules; the [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) owns commands and recovery. diff --git a/.agents/skills/dsh-error-handling/SKILL.md b/.agents/skills/dsh-error-handling/SKILL.md new file mode 100644 index 0000000000..f9dd7fd809 --- /dev/null +++ b/.agents/skills/dsh-error-handling/SKILL.md @@ -0,0 +1,30 @@ +--- +name: dsh-error-handling +description: Apply error-handling principles when designing or reviewing failures, recovery, retries, background tasks, and user-visible error reports. +--- + +# DeepSeek Harness Error Handling + +Define failures, their owners, and what remains safe after failure. + +## Typed, structured failures + +Expected failures need declared types, stable discriminants, and handling-relevant details. Reuse package error conventions, preserve causes, and separate machine identity from display text. Never route handling by parsing messages. Unexpected defects such as `assertNever` may remain plain `Error`; containment must never turn them into success. + +## Catch, recover, or stop + +For each failure, define handling and reporting owners, containment, and guarantees in the owning API documentation: valid invariants, absent/partial/committed/unknown effects, and cleanup responsibility. Background work retains these obligations. + +Catching limits exception propagation, not mutations. Only the affected state's owner can establish safe continuation. Otherwise stop dependent work, discard invalid state or instances, or escalate. Logging alone is not recovery. Retries require safe repetition, cancellation, and an explicit owner; finite operations need budgets, while continuing supervision needs lifecycle ownership and observable degradation. + +## Localized, actionable, visible reports + +Every final report needs a readable summary, affected operation, and safe next action. Localize through the receiving application's locale mechanism where one exists; other diagnostics use their existing output language. Keep sensitive internal details out of user-facing text. + +Untrusted state or unknown outcomes must be visible through UI or default-enabled, accessible logs: report the impact, uncertainty, system disposition, and safe next step. Persistent invalid state needs persistent visibility. Never claim success, rollback, or safe retry without guarantees. Reporting failures need an independent fallback. + +Verify resulting state and visible outcomes, not merely that an exception was caught. + +## Foundations + +Meyer's *Object-Oriented Software Construction* (1997): contracts and invariants; Sutter's *Exceptional C++* (1999): exception safety guarantees; Armstrong's [thesis](https://www.erlang.org/download/armstrong_thesis_2003.pdf) (2003): isolation and supervision. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index fe98baba29..46955d8abd 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -9,7 +9,7 @@ Find changes that remove maintained obligations: APIs, representations, lifecycl ## Establish scope and constraints -Read `AGENTS.md`, [architecture](../../../docs/architecture.md) before judging packages, [defensive patterns](../../../docs/defensive-patterns.md), and [testing policy](../../../docs/testing.md). Consult the relevant active Agent Notes for intentional decisions; use the [note rules](../../notes/README.md) for new records. Archived notes are historical examples, not current authority: never inspect, verify, or repair their outbound links. +Read `AGENTS.md`, [architecture](../../../docs/architecture.md) before judging packages, [defensive patterns](../../../docs/defensive-patterns/README.md), and [testing policy](../../../docs/testing.md). Consult the relevant active Agent Notes for intentional decisions; use the [note rules](../../notes/README.md) for new records. Archived notes are historical examples, not current authority: never inspect, verify, or repair their outbound links. The two LLM adapters are intentional. JSONL is the sole first-party Session persistence provider, while the backend-neutral Service Definition supports out-of-tree providers. Do not propose deleting an LLM twin or the persistence seam unless the user explicitly overrides that constraint. Unused members within a protected design remain candidates when their removal preserves its purpose. diff --git a/.agents/skills/dsh-official-plugin-artwork/SKILL.md b/.agents/skills/dsh-official-plugin-artwork/SKILL.md new file mode 100644 index 0000000000..2336c7dbc5 --- /dev/null +++ b/.agents/skills/dsh-official-plugin-artwork/SKILL.md @@ -0,0 +1,37 @@ +--- +name: dsh-official-plugin-artwork +description: Draw and review official DeepSeek Harness plugin icons, including experimental plugins. Use for package icon.svg assets and PluginArtwork glyphs; covers semantic references, proportions, lettering, transparency, package metadata, and designer review. Not for toolbar or action icons. +--- + +# Official plugin artwork + +Draw recognizable artwork that belongs beside the existing official icons. Official and official experimental plugins share this visual language; the UI label communicates experimental status. This guidance does not replace designer approval. + +## Establish the subject + +- Inspect the actual feature, its output, and its owning manifest before drawing. A badge-producing skill may need a tag, not a medal. Reuse designer-supplied assets; do not invent a brand mark or change product names, identifiers, or unrelated icons without approval. +- Read the [existing artwork](../../../packages/client/ui-primitives/src/plugin-artwork.tsx) and the nearest relevant package SVGs. Borrow one coherent treatment, not every reference's effects. Action icons remain with [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md#reuse-before-adding). +- Check every affected entry, list, detail, and component row. Bundle artwork and a component's name and icon can have separate metadata owners; changing one does not update the other. + +## Draw and refine + +1. **Start simple.** Use one dominant subject and only the detail needed to explain it. Related capabilities can share a metaphor; distinguish them through proportions, silhouette, or palette rather than extra panels, symbols, or badges. +2. **Judge optical size.** Use `width="36" height="36" viewBox="0 0 36 36"`. Center the artwork with deliberate margins; 18–22 units is a useful starting extent, not a requirement for every silhouette or wide text tag. The [page renderer](../../../packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx) owns display sizes and outer tile framing. Do not bake that tile into the SVG or enlarge a toolbar glyph to fill it. +3. **Keep geometry consistent.** Balance the main subject and secondary element. Align repeated marks and corners; keep tails and handles intentional. Refine one requested dimension at a time without changing approved parts. +4. **Use restrained color by default.** Balance saturation and brightness with neighboring icons; prefer existing palettes such as [Auto Authorization Review](../../../packages/experimental/auto-review/icon.svg) and [Codex subagent](../../../packages/subagent/subagent-codex/icon.svg). Reserve unusually vivid, bright, or neon colors for approved brand requirements, essential contrast, or explicit designer direction. Tune saturation separately from hue and lightness; do not make artwork gray or muddy merely to reduce saturation. Keep flat fills or short gradients and limited layering; avoid glow, drop shadows, glossy highlights, and emoji. Page CSS still uses semantic theme tokens. +5. **Align lettering by visible ink.** Use consistent letterforms, cap height, baseline, weight, and spacing. Compare rendered stroke edges, not just path coordinates: stroke caps and joins can make equal coordinates appear unequal. Export lettering as paths without a font dependency, and keep it readable at actual size. +6. **Make empty areas genuinely transparent.** A hollow lens must reveal the background, including where it overlaps another shape. Remove the underlying fill with a local mask or cutout; do not substitute a white or dark disk. Check both themes. + +## Export at the owner + +- Use the manifest's `icon` path and publish its resources. Update intentional bundle/component copies together; verify localized display names and descriptions when those are in scope. Do not change the feature's produced assets merely to change its plugin icon. +- Package images render through ``: export self-contained SVG geometry with local, uniquely named gradient, mask, and clip references. No scripts, event handlers, animation, external images/styles, fonts, or `foreignObject`. Inline React artwork needs per-instance IDs; separate image documents do not share IDs. +- Keep the asset change local. Do not add runtime dependencies, primitive exports, or renderer special cases just to display it. + +## Review and verify + +1. Show a few numbered alternatives before polishing uncertain geometry. Preserve their labels through review and apply the chosen version; an existing file or generated draft does not establish designer approval. Keep comparison artifacts in a gitignored directory, not the product package. +2. Compare old and new artwork beside unchanged references in light and dark themes, at actual row/card sizes as well as enlarged. Check balance, lettering, empty space, clipping, and image decoding. +3. Run `pnpm run verify-package-meta` for [package metadata and SVG artwork](../../../scripts/verify-package-meta.ts), `pnpm run constraints` for the canonical package payload, and `pnpm run verify-official-bundle-catalog`. Regenerate a stale catalog with `pnpm run gen-official-bundle-catalog`, never by hand. Structural checks do not replace visual review. +4. Build affected artifacts and inspect the real page through `dsh web`. Batch approved refinements before restarting a metadata-caching Host, then verify the entry and nested component rows in both themes. +5. Follow the repository's UI testing policy and [record-browser-gif](../record-browser-gif/SKILL.md) for a GUI PR. Obtain designer approval before landing new glyphs. Record reusable drawing principles here, not the history of rejected drafts. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 6567dde5dc..3d55a9dece 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -37,6 +37,7 @@ When the outgoing change adds or changes a resource-owning or asynchronous test, - **Expected-output placement:** a test whose selected recorded Session generation is replay input and expected persisted output belongs under top-level `snapshots/`, with `snapshot.yml` naming its shipped `dsh` profile and composition/header pin. Canonical parent files are `session[.vN].jsonl`, children are `session.[.vN].jsonl`, and the harness selects the highest generation per role. ARIA, geometry, generator, CLI, and unit expectations without that Session round trip stay beside their owning test under `tests/expected/`; do not place them in `snapshots/` or give them a `*.snapshot.ts` owner. Use the owning `test:expected`, `test:web`, or `test` lane. - **Profile and configuration placement:** cross-package behavior of a shipped `dsh` profile belongs under `apps/cli/tests/profiles/`; a package-specific Loader composition belongs under that package's `tests/fixtures/`. User-facing optional overlays live under `apps/cli/config/examples/` and pair with a guide under `docs/user/`. - **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke. +- **Repository test and script types:** `pnpm run build:artifacts` checks package projects only. Before claiming completion of TypeScript changes, run `pnpm run build:check` after the latest `build:artifacts`, or run `pnpm run build`; `build:check` reads the existing `build:artifacts` output and never rebuilds it. - **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets. Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook. diff --git a/.agents/skills/dsh-speed-up-perf/SKILL.md b/.agents/skills/dsh-speed-up-perf/SKILL.md index f8b5eb6141..491e39f81c 100644 --- a/.agents/skills/dsh-speed-up-perf/SKILL.md +++ b/.agents/skills/dsh-speed-up-perf/SKILL.md @@ -9,7 +9,7 @@ Turn a broad “make it faster” request into reproducible user-path measuremen ## Establish scope and current authority -Read [AGENTS.md](../../../AGENTS.md), [architecture](../../../docs/architecture.md), [testing policy](../../../docs/testing.md), [defensive patterns](../../../docs/defensive-patterns.md), and the affected packages’ instructions and Agent Notes. Use [CI test reliability](../dsh-ci-test-reliability/SKILL.md) for processes, clocks, browser tests, and asynchronous cleanup. +Read [AGENTS.md](../../../AGENTS.md), [architecture](../../../docs/architecture.md), [testing policy](../../../docs/testing.md), [defensive patterns](../../../docs/defensive-patterns/README.md), and the affected packages’ instructions and Agent Notes. Use [CI test reliability](../dsh-ci-test-reliability/SKILL.md) for processes, clocks, browser tests, and asynchronous cleanup. Agree on the user-visible endpoint, workload range, resource constraints, acceptable minor behavior differences, and stopping rule. Keep backend and browser end-to-end measurements separate: a fast history iterator or Client fold does not prove fast transport, paint, scrolling, or input response. Exclude model/network latency when measuring local overhead, and state that exclusion rather than calling the result complete product latency. diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 7cc2946f53..502c1333ea 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,5 @@ # AGENTS.md — GitHub Actions Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](workflows/ci-master.yml)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)). + +The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 9ea51311b6..0c869d008a 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -153,11 +153,15 @@ jobs: retention-days: 7 # Blacksmith x64 CI builds use 2 vCPU for compilation, bundling, and wheel smokes. + # Release macOS builds use larger GitHub runners; ordinary CI and direct + # builder dispatches retain the standard native target matrix. build: needs: [plan, sdk-wheel] name: ${{ matrix.target }} runs-on: >- - ${{ inputs.ci && !inputs.release && matrix.target == 'node24-linux-x64' + ${{ inputs.release && matrix.target == 'node24-macos-x64' && 'macos-15-large' + || inputs.release && matrix.target == 'node24-macos-arm64' && 'macos-15-xlarge' + || inputs.ci && !inputs.release && matrix.target == 'node24-linux-x64' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-2vcpu-ubuntu-2404' || inputs.ci && !inputs.release && matrix.target == 'node24-win-x64' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025' @@ -262,7 +266,7 @@ jobs: DSH_BUILD_CLIENT_PROFILE: official run: >- pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} - ${{ inputs.ci && github.event_name == 'pull_request' && '--artifacts-only' || '' }} + ${{ inputs.ci && github.event_name == 'pull_request' && '--build-artifacts' || '' }} - name: Resolve platform outputs (POSIX) id: runtime-posix diff --git a/.github/workflows/build-exe-for-ssh-helper.yml b/.github/workflows/build-exe-for-ssh-helper.yml new file mode 100644 index 0000000000..15455ddda2 --- /dev/null +++ b/.github/workflows/build-exe-for-ssh-helper.yml @@ -0,0 +1,127 @@ +name: Build SSH helper + +on: + workflow_call: + inputs: + targets: + description: Comma-separated native Node 24 targets; empty builds all four. + type: string + default: '' + release: + description: Retain a complete release build when another run starts. + type: boolean + default: false + workflow_dispatch: + inputs: + targets: + description: Linux/macOS x64/arm64 pkg targets; empty builds all four. + type: string + default: '' + +permissions: + contents: read + +concurrency: + group: ssh-helper-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !inputs.release }} + +env: + DSH_TELEMETRY_DISABLED: '1' + +jobs: + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + steps: + - name: Validate native targets + id: targets + env: + REQUESTED_TARGETS: ${{ inputs.targets }} + run: | + node --input-type=module <<'JS' + import { appendFileSync } from 'node:fs'; + const runners = { + 'node24-linux-x64': 'ubuntu-24.04', + 'node24-linux-arm64': 'ubuntu-24.04-arm', + 'node24-macos-arm64': 'macos-latest', + 'node24-macos-x64': 'macos-15-intel', + }; + const raw = process.env.REQUESTED_TARGETS; + const targets = raw ? raw.split(',').map(value => value.trim()) : Object.keys(runners); + if (new Set(targets).size !== targets.length || targets.some(target => !Object.hasOwn(runners, target))) throw new Error('Invalid or duplicate SSH helper target'); + appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify(targets.map(target => ({target, runner: runners[target]})))}\n`); + JS + + build: + needs: plan + name: ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - uses: actions/cache@v4 + with: + path: ~/.pkg-cache + key: ssh-pkg-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: ssh-pkg-${{ matrix.target }}- + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Prepare Linux compiler and sandbox + if: runner.os == 'Linux' + run: | + sudo apt-get update -q + sudo apt-get install -yq musl-tools bubblewrap + if sysctl kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + bwrap --ro-bind / / --dev /dev --unshare-pid --proc /proc --die-with-parent -- true + - name: Build compatible Linux PTY addon + if: runner.os == 'Linux' + run: bash scripts/prepare-linux-node-pty.sh + - name: Build native release archive + env: + HELPER_TARGET: ${{ matrix.target }} + run: pnpm exec tsx scripts/build-exe-for-ssh-helper.ts --target="$HELPER_TARGET" + - name: Verify relocated executable and confinement + env: + HELPER_TARGET: ${{ matrix.target }} + run: | + archives=(dist-exe/ssh-helper/*.tar.gz) + test "${#archives[@]}" -eq 1 + pnpm exec tsx scripts/verify-ssh-helper-artifact.ts --archive="${archives[0]}" --sandbox=required --report="dist-exe/ssh-helper/$HELPER_TARGET.json" + - name: Verify the packaged Landlock backend + if: runner.os == 'Linux' + env: + HELPER_TARGET: ${{ matrix.target }} + run: | + archives=(dist-exe/ssh-helper/*.tar.gz) + pnpm exec tsx scripts/verify-ssh-helper-artifact.ts --archive="${archives[0]}" --sandbox=required --backend=landlock-run --report="dist-exe/ssh-helper/$HELPER_TARGET.landlock.json" + - name: Verify OpenSSH on glibc 2.28 without Node + if: runner.os == 'Linux' + env: + HELPER_TARGET: ${{ matrix.target }} + run: | + archives=(dist-exe/ssh-helper/*.tar.gz) + test "${#archives[@]}" -eq 1 + pnpm exec tsx scripts/verify-ssh-helper-ssh.ts --archive="${archives[0]}" --report="dist-exe/ssh-helper/$HELPER_TARGET.ssh.json" + - name: Upload verified archive and evidence + uses: actions/upload-artifact@v7 + with: + name: ssh-helper-${{ matrix.target }} + path: dist-exe/ssh-helper/* + if-no-files-found: error diff --git a/.github/workflows/build-preview-cloudflare.yml b/.github/workflows/build-preview-cloudflare.yml index e8ad9bd2ec..c2ff5d3231 100644 --- a/.github/workflows/build-preview-cloudflare.yml +++ b/.github/workflows/build-preview-cloudflare.yml @@ -71,7 +71,7 @@ jobs: # build:preview packs the image through the packer's installed bin # (lib/bin.js), so neither half exists before the artifact build runs. - name: Build workspace - run: pnpm run build --artifacts-only + run: pnpm run build:artifacts - name: Build the preview page and pack the VFS image env: diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index 5feb9fb33c..5620e6af63 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -13,6 +13,7 @@ on: options: - larger-runner-benchmark - consolidated-runner-benchmark + - ssh-helper # New master pushes and manual runs replace obsolete checks in this workflow/ref. # Standby drills share cancellation; use completed runs as readiness evidence. @@ -30,6 +31,13 @@ env: DSH_TELEMETRY_DISABLED: '1' jobs: + ssh-helper: + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'ssh-helper' + name: ssh helper / native artifacts + uses: ./.github/workflows/build-exe-for-ssh-helper.yml + with: + targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64 + # These native runtime carriers are post-merge checks; release keeps all targets. python-runtime: if: github.event_name == 'push' && github.ref == 'refs/heads/master' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 397ac07b45..726bd070a5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -107,7 +107,7 @@ jobs: # the built artifact under plain Node, resolving plugins through real package # exports — the shape a real consumer runs. That requires a prior build. - name: Build (lib for the e2e example bins) - run: ${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }} + run: ${{ github.event_name == 'pull_request' && 'pnpm run build:artifacts --profile official' || 'pnpm run build:official' }} - name: Prepare Office runtime run: pnpm run prepare:primary-runtime --target linux-x64 --output .cache/e2e-office diff --git a/.github/workflows/publish-ssh-helper.yml b/.github/workflows/publish-ssh-helper.yml new file mode 100644 index 0000000000..f6740f210c --- /dev/null +++ b/.github/workflows/publish-ssh-helper.yml @@ -0,0 +1,101 @@ +name: Release (SSH helper) + +# Validation accepts any ref; publication requires the matching dsh-v tag. +on: + workflow_dispatch: + inputs: + publish: + description: Publish verified archives to GitHub Release. Must run from the matching dsh-v tag. + type: boolean + required: true + default: false + +permissions: + contents: read + +concurrency: + group: publish-ssh-helper-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.source.outputs.version }} + commit: ${{ steps.source.outputs.commit }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Resolve source and validate publication tag + id: source + env: + PUBLISH: ${{ inputs.publish }} + run: | + node --input-type=module <<'JS' + import { readFileSync, appendFileSync } from 'node:fs'; + import { execFileSync } from 'node:child_process'; + const {version} = JSON.parse(readFileSync('package.json', 'utf8')); + if (process.env.PUBLISH === 'true' && (process.env.GITHUB_REF_TYPE !== 'tag' || process.env.GITHUB_REF_NAME !== `dsh-v${version}`)) throw new Error('Publication requires the matching dsh-v tag'); + const commit = execFileSync('git', ['rev-parse', 'HEAD'], {encoding:'utf8'}).trim(); + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\ncommit=${commit}\n`); + JS + build: + needs: validate + uses: ./.github/workflows/build-exe-for-ssh-helper.yml + with: + release: true + verify: + needs: [validate, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@v8 + with: + pattern: ssh-helper-node24-* + path: dist-exe/ssh-helper + merge-multiple: true + - name: Require complete evidence from the selected source commit + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + SOURCE_COMMIT: ${{ needs.validate.outputs.commit }} + run: pnpm exec tsx scripts/ssh-helper/release.ts --directory=dist-exe/ssh-helper --version="$RELEASE_VERSION" --commit="$SOURCE_COMMIT" + - name: Upload verified release checksums + uses: actions/upload-artifact@v7 + with: + name: ssh-helper-release-checksums + path: dist-exe/ssh-helper/SHA256SUMS + if-no-files-found: error + + publish: + if: inputs.publish + needs: [validate, verify] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + pattern: ssh-helper-* + path: dist-exe/ssh-helper + merge-multiple: true + - name: Attach the verified archives to the GitHub release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" --verify-tag --draft --title "$RELEASE_TAG" --notes 'SSH helper executables for Linux and macOS.' + fi + gh release upload "$RELEASE_TAG" dist-exe/ssh-helper/*.tar.gz dist-exe/ssh-helper/SHA256SUMS diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fd8c9ab8a..b9def1dec1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,7 +146,7 @@ jobs: run: pnpm run release:verify --family dsh - name: Build - run: ${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }} + run: ${{ github.event_name == 'pull_request' && 'pnpm run build:artifacts --profile official' || 'pnpm run build:official' }} # Concurrency here is rehearsal-only: the credentialed publish workflows # invoke release:pack without the flag and keep the strictly serial path. diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml new file mode 100644 index 0000000000..b07aa91450 --- /dev/null +++ b/.github/workflows/windows-package.yml @@ -0,0 +1,116 @@ +name: Windows installer + +on: + workflow_dispatch: + inputs: + preset: + description: 'Machine preset (configured on the packaging runner)' + type: choice + options: [test, production] + default: test + required: true + signing: + description: 'unsigned: test installer; signed: use the configured EV hardware token' + type: choice + options: [unsigned, signed] + default: unsigned + required: true + build_version: + description: 'Version: auto recommended; or checkout product version / e.g. 0.2.1-alpha.1.20261009.1 (prefix must match checkout)' + type: string + default: auto + required: true + +permissions: + contents: read + +concurrency: + group: windows-desktop-package + cancel-in-progress: false + +jobs: + package: + runs-on: dsh-win-package-trial + timeout-minutes: 120 + defaults: + run: + shell: pwsh + env: + DSH_TELEMETRY_DISABLED: '1' + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + PACKAGE_SIGNING: ${{ inputs.signing }} + steps: + - name: Reset packaging checkout + working-directory: ${{ runner.temp }} + run: | + $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE) + $parent = [IO.Path]::GetFullPath($env:RUNNER_WORKSPACE) + if ([IO.Path]::GetDirectoryName($workspace) -ne $parent) { throw 'Checkout must be a direct child of RUNNER_WORKSPACE' } + for ($item = [IO.DirectoryInfo]::new($workspace); $null -ne $item; $item = $item.Parent) { + if ($item.Exists -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Checkout ancestors cannot be links' } + } + node -e "const fs = require('node:fs'); fs.rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 }); fs.mkdirSync(process.argv[1])" $workspace + if ($LASTEXITCODE -ne 0) { throw 'Checkout cleanup failed' } + - uses: actions/checkout@v7.0.1 + id: checkout + with: + persist-credentials: false + clean: true + - uses: pnpm/action-setup@v5.0.0 + with: + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Install locked dependencies + run: | + pnpm install --frozen-lockfile + exit $LASTEXITCODE + - name: Validate preset and resolve build version + id: settings + env: + PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} + run: node apps/desktop/scripts/prepare-windows-ci.mjs + - name: Check packaging configuration and toolchain + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: | + $packageArgs = @('run', 'package:desktop:win:x64', '--check', '--build-version', $env:PACKAGE_VERSION) + if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } + pnpm @packageArgs + exit $LASTEXITCODE + - name: Build installer and verify packaged runtime + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: | + $packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION) + if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } + pnpm @packageArgs + exit $LASTEXITCODE + - name: Retain packaging diagnostics + if: always() && steps.checkout.outcome == 'success' + uses: actions/upload-artifact@v7.0.1 + with: + name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: apps/desktop/.desktop-build/packaging-runs/ + if-no-files-found: warn + retention-days: 14 + - name: Retain verified unsigned installer + if: success() && inputs.signing == 'unsigned' + uses: actions/upload-artifact@v7.0.1 + with: + name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: apps/desktop/.desktop-build/targets/win-x64/unsigned-artifacts/*.exe + if-no-files-found: error + retention-days: 14 + - name: Retain verified signed installer + if: success() && inputs.signing == 'signed' + uses: actions/upload-artifact@v7.0.1 + with: + name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: apps/desktop/.desktop-build/targets/win-x64/artifacts/*.exe + if-no-files-found: error + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index 38e5ae46cf..7bae0a79c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,9 @@ pnpm run test:snapshot:record # re-record expected outputs (needs key) pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection -pnpm run build # tsc emits lib/types, tsdown bundles runtime +pnpm run build # build:artifacts + build:check, concurrently +pnpm run build:artifacts # artifacts: tsc emits lib/types, tsdown bundles runtime, Web +pnpm run build:check # typecheck aggregate tests/scripts against current build:artifacts output pnpm run hygiene # publint + workspace/package/dependency checks + NodeNext consumer check pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run doc-sync # documentation gates (scripts/run-gates.ts) @@ -100,7 +102,7 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg pnpm run website:build # VitePress build (doubles as dead-link check) pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) pnpm run demo:ptc -- "task" # headless PTC mode run (needs key) -pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build +pnpm run dev:web | dev:desktop # build:artifacts, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options ``` @@ -115,11 +117,12 @@ Before pushing, follow [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/ - Match evidence to the surface: focused behavior tests, model/user-output snapshots, `doc-sync` for docs, built smokes for published paths, and real-API e2e for providers. - Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. - `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). +- Develop with `build:artifacts`; before claiming completion, run `build:check` after the latest `build:artifacts`, or `build` ([layout](docs/development.md#typescript-project-layout)). - **Web browser automation and GIF recording:** launch with `pnpm dsh web --patch apps/web/tests/pin-browse-picker.overlay.yml` to use the [in-page directory picker](apps/web/tests/pin-browse-picker.overlay.yml); omit this override only when testing native picker behavior explicitly. ## Secrets / .env -Windows packaging/signing: [required reading](apps/desktop/README.md#windows-ev-signing). +Windows packaging/signing: [required reading](apps/desktop/windows-signing/README.md). Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy. @@ -128,7 +131,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - Packages use `@deepseek-ai/dsh-`; vendor is [rescoped](docs/rescope.md) and `private: true`. Harness packages declare `@deepseek-ai/cordis` in `peerDependencies`/`devDependencies`. Workspace dependency sections use DSH `workspace:*`, vendor/native `workspace:~` ([rules](.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. -- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`. Public service methods document parameters and non-void returns. `SessionEventMap` members are required-on-read by default — builds that do not know a type refuse the log unless the event carries the envelope's `ignorable: true`; only structural format changes bump `SESSION_FORMAT_VERSION` ([mechanism](.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)). +- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`. Public service methods document parameters and non-void returns. `SessionEventMap` members are required-on-read unless the envelope carries `ignorable: true`. Bump `SESSION_FORMAT_VERSION` only when existing discriminators cannot prevent unsafe interpretation; same-version changes require safe reader behavior ([review](.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md)). - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). - **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event. @@ -143,12 +146,14 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **No new assertions to `unknown`** (`as unknown` or ``). Preserve or reduce the exact legacy baseline; use typed values or validation for replacements ([rule](.agents/notes/implemented/process/2026-09-19-no-unknown-casts.md)). - **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). - **Keep compiler faces explicit.** A package with both Host and Client programs exposes face-specific leaf configs and a solution-only root; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). +- **Error handling:** use typed, structured failures and localized, actionable reports. Define handling ownership, containment, and failure guarantees; stop dependent work and visibly report untrusted state or unknown outcomes. Follow [dsh-error-handling](.agents/skills/dsh-error-handling/SKILL.md). - **An empty `catch` names the error** and why; keep its `try` to one statement. - **Keep comments local.** Do not restate code, expand unrelated comments, or explain distant behavior without local need ([rationale](.agents/skills/dsh-prose-standard/SKILL.md)). - **Ban `prove` + `nance`** ([rule](.agents/notes/implemented/process/2026-08-26-ban-ambiguous-origin-label.md)). - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). +- **Official plugin artwork:** use [dsh-official-plugin-artwork](.agents/skills/dsh-official-plugin-artwork/SKILL.md) for official and official experimental plugin icons; obtain designer approval before landing new glyphs. - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). @@ -161,7 +166,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and ## Defensive patterns -Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle, concurrency, subprocess, or teardown work. +Read [docs/defensive-patterns/README.md](docs/defensive-patterns/README.md) before changing a package surface, remote service, client component or state, conversation or Session behavior, or lifecycle, concurrency, subprocess, and teardown code. Its [review principles](docs/defensive-patterns/README.md#why-these-rules) apply per artifact. ## Type safety and documentation diff --git a/Makefile b/Makefile index 4b95df6766..0836489ef8 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,14 @@ # Short names for the Web and Desktop application commands. The package.json # scripts they call stay the source of truth; docs/development.md documents both. .DEFAULT_GOAL := help -.PHONY: help build web desktop dev-web dev-desktop +.PHONY: help build clean web desktop dev-web dev-desktop PNPM ?= pnpm ARGS ?= help: @echo "make build pnpm run build complete repository build" + @echo "make clean pnpm run clean remove build outputs and safe deleted-package residue" @echo "make web pnpm run start:web serve the built Web artifacts from source" @echo "make desktop pnpm run start:desktop launch the built Desktop artifacts" @echo "make dev-web pnpm run dev:web build, serve, and rebuild Web on source edits" @@ -18,6 +19,9 @@ help: build: $(PNPM) run build +clean: + $(PNPM) run clean + web: $(PNPM) run start:web $(ARGS) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 741c98db08..3fd65e81a2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Run `pnpm run gen-third-party-notices` after changing one of its inputs, or `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). @@ -247,6 +247,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 | | [`pnpm`](https://github.com/pnpm/pnpm) | MIT | | [`publint`](https://github.com/publint/publint) | MIT | +| [`selfsigned`](https://github.com/jfromaniello/selfsigned) | MIT | | [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause | | [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT | | [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 | @@ -268,6 +269,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | Package | License | Role | | --- | --- | --- | | [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend | +| [`pillow`](https://github.com/python-pillow/Pillow) | MIT-CMU | Python project dependency | | [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` | | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | diff --git a/apps/cli/package.json b/apps/cli/package.json index 3da15fcad8..b841d617f0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -90,7 +90,7 @@ "@deepseek-ai/schemastery": "workspace:~", "commander": "^15.0.0", "js-yaml": "^4.3.2", - "node-addon-require-builtin": "^0.1.6", + "node-addon-require-builtin": "^0.1.9", "@deepseek-ai/dsh-http-proxy": "workspace:*", "@deepseek-ai/dsh-mcp-resources": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index da6e0863ff..69b8cfee07 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -9,8 +9,8 @@ en: e3a159750f5f9ed3 zh: e02d9004fcc239f9 /dsh-cli-behavior-reference/profile-boot/app-arguments: - en: be06f11a3b585d0f - zh: 28c7ee6e0d86dc3b + en: 3d2f4cf6a0afc3cd + zh: 9fe7e8f6a4acb596 /dsh-cli-behavior-reference/profile-boot/config-schema-dump: en: a356f8a68f3b3685 zh: c718713b5d709295 @@ -21,8 +21,8 @@ en: 8d2cfff9bab7327f zh: 21515ab6e6798504 /dsh-cli-behavior-reference/web-profile: - en: 536c6883a0d54b14 - zh: 6dd2469b71189c87 + en: ee6c00e55a2f0a10 + zh: 404942e397e213ab /dsh-cli-behavior-reference/shared-deployment-behavior: en: 6a7abc9339e8f37f zh: 133adb834c8e195d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index e8105bab78..3123b7898e 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -35,7 +35,7 @@ The shipped apps own these command lines: | Profile | Arguments | |---|---| -| `web` | `--host`, `--port`, `--public-url`, repeatable `--trusted-host`, `--no-open` | +| `web` | `--host`, `--port`, `--tls-cert`, `--tls-key`, `--public-url`, repeatable `--trusted-host`, `--no-open` | | `headless` | the task text, as the positional argument | | `sdk` | no options; stdio carries the JSON-RPC protocol | | `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol | @@ -106,19 +106,23 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web profile -`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere. +`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed listener values, and `--tls-cert`/`--tls-key` set the listener's `tls` pair. The host must name one concrete local address, not a wildcard (`--host 0.0.0.0` exits with a usage error); `hostname -i` may list several, so pass one. Repeatable `--trusted-host` values are collected in `ctx.webStartup.trustedHosts`; a deployment expression may add its own authorities. `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere. -`--public-url ` advertises one HTTP(S) application root — with an optional forwarding prefix — in place of the listener's loopback URL. It grants no trust, so the browser-visible authority still has to be named with `--trusted-host`; [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what the proxy in front must provide. +`--public-url ` advertises one HTTP(S) application root — with an optional forwarding prefix — in place of the listener's bind-address URL (loopback for a loopback bind). It grants no trust, so the browser-visible authority still has to be named with `--trusted-host`; [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what the proxy in front must provide. + +`--tls-cert ` and `--tls-key ` make the listener serve HTTPS in place of plain HTTP. They are one setting: both or neither, and each names a file resolved against the process working directory. The certificate file holds the full chain, including intermediates; the key file holds an unencrypted PEM private key. A missing, unreadable, empty, or invalid pair fails startup, which never falls back to HTTP, and a replacement pair takes effect only after a listener reload or a process restart because the carrier reads the files once and nothing obtains, renews, or watches certificates. HTTPS also grants no trust: the browser must trust the certificate and open an authority its names cover, Connection still authenticates each request and checks Host/Origin, and the session cookie is `Secure` because the receiving listener serves HTTPS. The default port stays 3080, and `--public-url` remains an independent advertisement. ```sh dsh web +dsh web --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example +dsh web --tls-cert ./server-chain.pem --tls-key ./server-key.pem dsh web --no-open dsh web --patch ./extra.cordis.yml dsh web --dump-config dsh web --help ``` -The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default and, for a local launch, opens that canonical host URL — the advertised root when `--public-url` names one — only after the complete Loader tree settles. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses the browser handoff because the SSH client or editor owns the local forwarded address; the host URL is still printed. The CLI intentionally does not support `--host 0.0.0.0` and exits with a usage error. Immediately before a local handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, a diagnostic on stderr states the reason, leaves the server running, and names the URL for manual use. `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. +The production Web runner needs built package and frontend artifacts (`pnpm run build`). It prints the advertised `--public-url` when configured, otherwise the listener's bind-address URL with the listener's scheme (default `http://127.0.0.1:3080`, or `https://` when TLS is configured). The Host/Origin fence accepts the bind IP directly; a proxy or DNS authority still needs `--trusted-host`. The startup line and default-browser handoff wait for the complete Loader tree. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses browser opening, not the startup line. Immediately before a browser handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, stderr names the reason and points to the startup URL while the server keeps running. Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 2471de196e..9a43ca2858 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -37,7 +37,7 @@ dsh rescue | Profile | 参数 | |---|---| -| `web` | `--host`、`--port`、`--public-url`、可重复的 `--trusted-host`、`--no-open` | +| `web` | `--host`、`--port`、`--tls-cert`、`--tls-key`、`--public-url`、可重复的 `--trusted-host`、`--no-open` | | `headless` | 任务文本,作为位置参数 | | `sdk` | 无选项;stdio 携带 JSON-RPC 协议 | | `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 | @@ -108,19 +108,23 @@ dsh --profile tui ## Web Profile -`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。 +`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 与 `--port` 覆盖组合的监听器取值,`--tls-cert`/`--tls-key` 则设置监听器的 `tls` 证书对。host 必须指名一个具体的本机地址,不能是通配地址(`--host 0.0.0.0` 会以用法错误退出);`hostname -i` 可能列出多个,请只传一个。可重复的 `--trusted-host` 值会收集到 `ctx.webStartup.trustedHosts`;部署表达式可以添加自己的 authority。`--no-open` 只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。 -`--public-url ` 公告唯一的 HTTP(S) 应用根——可带转发前缀——替代监听器的 loopback URL。它不授予信任,因此浏览器可见的 authority 仍需用 `--trusted-host` 点名;[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了前置代理必须提供的内容。 +`--public-url ` 公告唯一的 HTTP(S) 应用根——可带转发前缀——替代监听器的绑定地址 URL(回环绑定时为回环地址)。它不授予信任,因此浏览器可见的 authority 仍需用 `--trusted-host` 点名;[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了前置代理必须提供的内容。 + +`--tls-cert ` 与 `--tls-key ` 让监听器以 HTTPS 取代明文 HTTP 提供服务。它们属于同一个设置:要么都给,要么都不给;两者各是相对进程工作目录解析的路径。证书文件包含完整证书链(含中间证书),密钥文件为不带口令的 PEM 私钥。文件缺失、不可读、为空或无效时启动失败,且绝不回退到 HTTP;由于载体只读取一次文件,且既不获取、不续期也不监视证书,替换后的证书对只有在重新加载监听器或重启进程后才生效。HTTPS 同样不授予信任:浏览器必须信任该证书并访问其名称覆盖的 authority,Connection 仍认证每个请求并校验 Host/Origin,而由于接收请求的监听器提供 HTTPS,会话 cookie 带 `Secure`。默认端口仍为 3080,`--public-url` 仍是相互独立的公告。 ```sh dsh web +dsh web --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example +dsh web --tls-cert ./server-chain.pem --tls-key ./server-key.pem dsh web --no-open dsh web --patch ./extra.cordis.yml dsh web --dump-config dsh web --help ``` -生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`;本机启动时,只在完整 Loader 配置树结算后才用默认浏览器打开该规范宿主机 URL(配置了 `--public-url` 时即为公告根)。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有;宿主机 URL 仍会打印。CLI 有意不支持 `--host 0.0.0.0`,并会以用法错误退出。本机交接前会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 诊断会说明原因、给出 URL 供手动访问,服务器仍继续运行。`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 +生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。配置了 `--public-url` 时打印公告地址,否则打印采用监听器协议的绑定地址 URL(默认为 `http://127.0.0.1:3080`,配置 TLS 时为 `https://`)。Host/Origin 栅栏直接接受绑定 IP;代理或 DNS authority 仍需 `--trusted-host`。启动日志与默认浏览器交接都会等待完整 Loader 配置树结算。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会抑制浏览器打开,但仍打印启动 URL。浏览器交接前会打印 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 会说明原因并指向启动 URL,服务器仍继续运行。 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 09a0f39b1f..4ad8abce2d 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -381,7 +381,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }) expect(wildcardHost.code).toBe(1) expect(wildcardHost.stdout).toBe('') - expect(wildcardHost.stderr).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead') expect(wildcardHost.stderr).not.toContain('dsh web: http://') const headlessHelp = await runBuiltBin(['headless', '--help'], { diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index 44d976bed4..623958ea9c 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -308,9 +308,15 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option await expect.poll(() => titleRequests(following.agent).length).toBe(1) expect(titleRequests(following.agent)[0]?.data.titleProvider).toBe('session-title-all-prompts-llm') await expect.poll(() => ctx.sessionTitle.get(following.agent.session)?.source.kind).toBe('provider') + const currentTitle = ctx.sessionTitle.get(following.agent.session)?.title + const firstInput = titleRequests(following.agent)[0]?.data.messages[0]?.content[0] + expect(firstInput?.type === 'text' && firstInput.text).toContain('Generate the session title from this JSON array') await turn(following.agent, 'Continue the growing conversation.', [textResponse('TWO')]) await expect.poll(() => titleRequests(following.agent).length).toBe(2) expect(titleRequests(following.agent).every(event => event.data.titleProvider === 'session-title-all-prompts-llm')).toBe(true) + const followingInput = titleRequests(following.agent)[1]?.data.messages[0]?.content[0] + expect(followingInput?.type === 'text' && followingInput.text) + .toContain(`Update the session title from this JSON object:\n{"currentTitle":${JSON.stringify(currentTitle)},`) await select(bundles.titles, false) const restored = await agent() await turn(restored.agent, 'Restore first-prompt titles.', [textResponse('ONE')]) diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-conversation-updates.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-conversation-updates.patch.yml new file mode 100644 index 0000000000..6d2a8dde06 --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-conversation-updates.patch.yml @@ -0,0 +1,36 @@ +# pi-ai Kimi route with dynamic prompt and tool updates over the shipped headless profile. +- id: headless-startup + disabled: true + +- id: headless-runner + disabled: true + +- id: llm-deepseek + disabled: true + +- id: llm-pi-ai + config: + providers: + moonshotai: + apiKeyEnv: MOONSHOT_API_KEY + baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL + streamIdleTimeoutMs: 1000 + +- id: agent-loop + config: + agents: + - id: main + provider: moonshotai + model: kimi-k3 + cwd: !!js process.cwd() + +- id: agent-instructions + disabled: true + +# The fixture counts every model request, so title generation stays off. +- id: session-title-llm + disabled: true + +- insert: + - id: dynamic-tool-prompt-updates + name: '../../../../../../../packages/test-support/session-snapshot/tests/fixtures/dynamic-tool-prompt-updates.ts' diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index d9fe3a472c..6406d63753 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -42,6 +42,7 @@ const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', im const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.patch.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.patch.yml', import.meta.url)) const piAiDefaultsConfigPath = fileURLToPath(new URL('./fixtures/pi-ai-defaults.patch.yml', import.meta.url)) +const piAiConversationUpdatesConfigPath = fileURLToPath(new URL('./fixtures/pi-ai-conversation-updates.patch.yml', import.meta.url)) const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.patch.yml', import.meta.url)) const headlessSessionExpected = join(goldensDir, 'headless-profile', 'session.expected.jsonl') const headlessReasoningExpected = join(goldensDir, 'headless-profile', 'reasoning.stderr.expected.txt') @@ -148,6 +149,58 @@ async function deepseekDefaultsServer( } } +/** Script Chat Completions responses in request order: `read`, then `snapshot_ping`, then text. */ +async function piAiConversationServer(): Promise { + const requests: JsonObject[] = [] + const agentReplies = [ + { tool_calls: [{ index: 0, id: 'call_read', type: 'function', function: { name: 'read', arguments: '{"file_path":"task.txt"}' } }] }, + { tool_calls: [{ index: 0, id: 'call_ping', type: 'function', function: { name: 'snapshot_ping', arguments: '{}' } }] }, + { content: 'DONE' }, + ] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + requests.push(JSON.parse(body) as JsonObject) + const delta = agentReplies[requests.length - 1] ?? { content: 'UNEXPECTED' } + const finish = 'tool_calls' in delta ? 'tool_calls' : 'stop' + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end([ + `data: ${JSON.stringify({ choices: [{ delta }] })}`, + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: finish }], usage: { prompt_tokens: 3, completion_tokens: 1 } })}`, + 'data: [DONE]', + '', + ].join('\n\n')) + }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('pi-ai conversation snapshot server has no port') + return { + url: `http://127.0.0.1:${address.port}`, + requests, + paths: [], + close: () => new Promise(resolve => server.close(() => { resolve() })), + } +} + +/** Project one Chat Completions request to its fixture-owned declarations and message roles, tools, and prompt markers. */ +function piAiTranscript(request: JsonObject): JsonObject { + const names = (tools: unknown): string[] => Array.isArray(tools) + ? tools.map(tool => ((tool as { function: { name: string } }).function.name)) + : [] + return { + // Profile tools change independently of this scenario; only the fixture's tool is recorded. + tools: names(request.tools).filter(name => name === 'snapshot_ping'), + messages: (request.messages as JsonObject[]).map(message => ({ + role: message.role, + ...message.tools === undefined ? {} : { tools: names(message.tools) }, + ...typeof message.content === 'string' && message.content.includes('Dynamic tool guidance') ? { guidance: true } : {}, + })), + } +} + function parseJsonl(content: string): JsonObject[] { return content.split('\n') .filter(line => line.trim().length > 0) @@ -745,6 +798,113 @@ describe('headless stream-json snapshots', () => { } }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('sends pi-ai Kimi prompt and tool updates in conversation history', async () => { + const server = await piAiConversationServer() + try { + const result = await runLoaderSmoke({ + label: 'pi-ai Kimi conversation updates headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-pi-ai-conversation-updates-', + binScript, + libBinScript: binScript, + configPath: piAiConversationUpdatesConfigPath, + binArgs: [ + piAiConversationUpdatesConfigPath, + 'read task.txt and follow it', + ], + tsconfigPath, + prepare: cwd => writeFile(join(cwd, 'task.txt'), 'Call snapshot_ping once.\n'), + env: { + MOONSHOT_API_KEY: 'snapshot-key', + DSH_SNAPSHOT_BASE_URL: server.url, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + }) + + expect(result.stderr).toBe('') + expect(parseJsonl(result.stdout).at(-1)).toMatchObject({ type: 'result', output: 'DONE' }) + expect(server.requests.map(piAiTranscript)).toMatchInlineSnapshot(` + [ + { + "messages": [ + { + "role": "system", + }, + { + "role": "user", + }, + { + "role": "user", + }, + ], + "tools": [], + }, + { + "messages": [ + { + "role": "system", + }, + { + "role": "user", + }, + { + "role": "user", + }, + { + "role": "assistant", + }, + { + "role": "tool", + }, + { + "guidance": true, + "role": "system", + }, + { + "role": "system", + "tools": [ + "snapshot_ping", + ], + }, + ], + "tools": [], + }, + { + "messages": [ + { + "role": "system", + }, + { + "role": "user", + }, + { + "role": "user", + }, + { + "role": "assistant", + }, + { + "role": "tool", + }, + { + "guidance": true, + "role": "system", + }, + { + "role": "assistant", + }, + { + "role": "tool", + }, + ], + "tools": [], + }, + ] + `) + } finally { + await server.close() + } + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('runs a keyless Agent Team with peer mail, dependent tasks, waiting, and Lead aggregation', async () => { let projection: unknown const result = await runLoaderSmoke({ diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 806f081a77..764f353345 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -293,9 +293,9 @@ describe('PTC mode typed values: keyless real-process contracts', () => { await ctx.plugin(CordisInspectProviders) await ctx.plugin(ToolCordis) const agent = { + ctx, id: SessionId('ptc-cordis'), session: ctx.sessions.create(SessionId('ptc-cordis'), { meta: { cwd: process.cwd() } }), - ctx, } as Agent const value = completion(await runCode(ctx, ` diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index f804cde4fa..5ee1288135 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -494,8 +494,11 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli }, () => stderr) const names = (requests[0]!.tools as { name: string }[]).map(value => value.name) expect(names.includes('load_workspace_dependencies')).toBe(enabled) + expect(JSON.stringify(requests[0]!.messages).includes('`office`')).toBe(enabled && mode !== 'missing-assets') + expect(names).not.toContain('office_tools') + expect(names).not.toContain('render_document') for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets' && mode !== 'python-only') + expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets') } if (mode === 'wrong-type') { expect(requests).toHaveLength(2) @@ -524,7 +527,7 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli expect.objectContaining({ role: 'user', content }), ])) } - if (mode === 'python-only') expect(stderr).toContain('node') + if (mode === 'python-only') expect(stderr).not.toContain('ENOENT') if (mode === 'missing-assets') expect(stderr).toContain('check_office.py') await send(3, 'shutdown') const exit = await child diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts index d94a88a304..2c90e5fc6e 100644 --- a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts +++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts @@ -2,7 +2,7 @@ * Agent discovery of the installed Office CLI through the shipped SDK profile. * Defaults to built Node packages. DSH_OFFICE_TEST_EXECUTABLE/ENTRY select a * relocated Node or Electron carrier; CARRIER=sdk selects the standalone executable. - * NODE/CLI/ASSETS under the same prefix supply Desktop resources outside ASAR. + * NODE/CLI/ASSETS under the same prefix select Desktop command and skill resources outside ASAR. * API=openai-completions selects a development gateway instead of the default Messages API. */ import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile, chmod } from 'node:fs/promises' @@ -29,16 +29,13 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent 'word/document.xml': strToU8('Office CLI discovery'), })) const patch = join(root, 'office.patch.yml') + const node = process.env.DSH_OFFICE_TEST_NODE, cli = process.env.DSH_OFFICE_TEST_CLI + const assetRoot = process.env.DSH_OFFICE_TEST_ASSETS await writeFile(patch, JSON.stringify([ ...(process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? [{ id: 'llm-deepseek', disabled: true }, { id: 'llm-pi-ai', config: { providers: { 'office-test': { api: 'openai-completions', apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: process.env.DEEPSEEK_BASE_URL, compat: { thinkingFormat: 'deepseek' }, models: [{ id: process.env.MODEL_NAME ?? 'deepseek-v4-flash', contextWindow: 128000, maxTokens: 8000 }] } } } }] : []), { id: 'session-persistence-jsonl', config: { root: join(root, 'sessions'), compression: 'none' } }, - ...carrier === 'sdk' ? [] : [{ insert: [{ - id: 'office-cli-discovery', name: '@deepseek-ai/dsh-skill-office', config: { - ...(process.env.DSH_OFFICE_TEST_NODE ? { node: process.env.DSH_OFFICE_TEST_NODE } : {}), - ...(process.env.DSH_OFFICE_TEST_CLI ? { cli: process.env.DSH_OFFICE_TEST_CLI } : {}), - ...(process.env.DSH_OFFICE_TEST_ASSETS ? { assetRoot: process.env.DSH_OFFICE_TEST_ASSETS } : {}), - }, - }] }], + { id: 'skill-office', disabled: false }, + { id: 'skill-office', config: { ...assetRoot === undefined ? {} : { assetRoot }, ...node === undefined ? {} : { node }, ...cli === undefined ? {} : { cli } } }, ])) // A private POSIX tool directory also excludes /usr/bin/node on Linux. const path = join(root, 'bin') @@ -54,7 +51,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent await writeFile(decoy, '#!/bin/sh\nprintf invoked > ' + "'" + decoyMarker.replaceAll("'", "'\\''") + "'" + '\nexit 86\n') await chmod(decoy, 0o755) } - const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v libreoffice-kit'], { env: { PATH: path }, reject: false }) + const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v dsoffice'], { env: { PATH: path }, reject: false }) expect(lookup.exitCode, 'the fixture must not expose Node or the Office CLI through PATH').not.toBe(0) const child = execa(executable, [ ...carrier === 'sdk' ? [] : [entry], '--profile', 'sdk', '--patch', patch, @@ -103,10 +100,12 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent const commands = calls.filter(call => call.name === 'bash') expect(commands.length, diagnostic).toBeGreaterThan(0) expect(calls.findIndex(call => call.name === 'skill'), diagnostic).toBeLessThan(calls.findIndex(call => call.name === 'bash')) - const commandText = JSON.stringify(commands) - for (const fragment of ['cli.js', 'capabilities', 'convert']) expect(commandText, diagnostic).toContain(fragment) + const commandText = commands.map((call) => { + if (typeof call.arguments !== 'string') throw new Error('Shell tool call did not contain serialized arguments') + return (JSON.parse(call.arguments) as { command: string }).command + }).join('\n') + for (const operation of ['capabilities', 'convert']) expect(commandText, diagnostic).toMatch(new RegExp(`\\s${operation}\\b`, 'u')) expect(existsSync(decoyMarker), 'the agent must not invoke system Office when the bundled CLI is supplied').toBe(false) - expect(commandText, 'the skill supplies an entry; searching for other Office binaries is unnecessary').not.toMatch(/(?:which|whereis|command -v|find).{0,100}(?:libreoffice|soffice)/iu) const capabilities = JSON.parse(await readFile(join(root, 'capabilities.json'), 'utf8')) as { runtime: { version: string; cliPath: string } } expect(capabilities.runtime.version).toBe('0.1.5') expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u) diff --git a/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts new file mode 100644 index 0000000000..9c47584c49 --- /dev/null +++ b/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts @@ -0,0 +1,12 @@ +/** Ordinary Web leaves Office authoring composition to its carrier. */ +import { expect, it } from 'vitest' +import { withDefaultWeb } from './default-web-process.ts' + +it('does not mount Office skills, commands, or authoring dependencies in ordinary Web', async (test) => { + await withDefaultWeb(test, async ({ request }) => { + const roster = await request('roster') + for (const name of ['skill-office', 'office-commands', 'tool-workspace-dependencies']) { + expect(roster.plugins.some(plugin => plugin.name === name)).toBe(false) + } + }) +}) diff --git a/apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts index 5ed381e1d0..b596f4e943 100644 --- a/apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts @@ -1,7 +1,8 @@ -/** Built Web-profile acceptance for the advertised public root and browser authentication behind a prefix-stripping proxy. */ +/** Built Web-profile acceptance for concrete bind addresses, the advertised public root, and browser authentication. */ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { request as httpRequest, type IncomingMessage } from 'node:http' +import { request as httpsRequest } from 'node:https' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createInterface } from 'node:readline' @@ -9,8 +10,10 @@ import { Readable } from 'node:stream' import { fileURLToPath, pathToFileURL } from 'node:url' import { execa } from 'execa' import { describe, expect, it } from 'vitest' +import { probeNonLoopbackIpv4, probeZonedLoopback } from '../../../../../../scripts/test-non-loopback-address.ts' import { startPrefixProxy } from '../../../../../../apps/web/tests/prefix-proxy.ts' import { PROCESS_SHUTDOWN_TIMEOUT_MS } from '../../../../src/process-shutdown.ts' +import { materializeTlsFixture } from '../../../../../../packages/host/webserver/tests/tls-fixture.ts' const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') @@ -19,6 +22,18 @@ const builtArtifactsExist = existsSync(dshBin) /** Browser-facing mount the reference proxy serves and strips. */ const MOUNT = '/tools/dsh/' +const NON_LOOPBACK_IPV4 = builtArtifactsExist ? await probeNonLoopbackIpv4() : undefined + +const ZONED_LOOPBACK = builtArtifactsExist ? await probeZonedLoopback() : undefined + +/** + * Every spelling that binds IPv6 loopback. `::0.0.0.1` is the same address as + * `::1`, and the zoned rows reuse the probed scope on that same address. + */ +const IPV6_LOOPBACKS = ZONED_LOOPBACK === undefined + ? ['::1', '::0.0.0.1'] + : ['::1', '::0.0.0.1', ZONED_LOOPBACK, ZONED_LOOPBACK.replace('::1%', '::0.0.0.1%')] + /** One live Web child, as `execa` types it. */ type WebChild = ReturnType @@ -155,16 +170,22 @@ function expectCleanExit(stopped: StoppedWeb): void { } /** - * One HTTP request that connects to an explicit transport address while - * presenting an explicit browser-facing authority, as a proxy would. + * One request to an explicit transport address with a browser-facing authority. + * HTTPS verifies the certificate and hostname against the supplied CA or system roots. */ async function request( - dial: string, port: number, path: string, host: string, method: 'GET' | 'POST', cookie?: string, + protocol: 'http:' | 'https:', + dial: string, port: number, path: string, host: string, method: 'GET' | 'POST', cookie?: string, ca?: string, ): Promise { const { promise, resolve, reject } = Promise.withResolvers() - const req = httpRequest({ + const req = (protocol === 'http:' ? httpRequest : httpsRequest)({ host: dial, port, path, method, - headers: { host, 'content-type': 'application/json', ...cookie === undefined ? {} : { cookie } }, + ...ca === undefined ? {} : { ca }, + headers: { + host, 'content-type': 'application/json', + ...protocol === 'http:' ? {} : { origin: `https://${host}` }, + ...cookie === undefined ? {} : { cookie }, + }, }, resolve) req.once('error', reject) req.setTimeout(10_000, () => { req.destroy(new Error('HTTP request timed out')) }) @@ -181,16 +202,45 @@ async function request( /** Establish the browser session needed by the protected API, keeping the minted cookie's own attributes. */ async function exchange( - dial: string, port: number, path: string, host: string, + protocol: 'http:' | 'https:', + dial: string, port: number, path: string, host: string, ca?: string, ): Promise<{ cookie: string; setCookie: string }> { - const response = await request(dial, port, path, host, 'GET') + const response = await request(protocol, dial, port, path, host, 'GET', undefined, ca) const setCookie = response.headers.get('set-cookie') await response.arrayBuffer() if (setCookie === null) throw new Error(`entry returned ${String(response.status)} without a browser cookie`) return { cookie: setCookie.split(';', 1)[0]!, setCookie } } -describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root', () => { +describe.skipIf(!builtArtifactsExist)('dsh Web profile bind address and advertised root', () => { + it('serves the default advertised URL over verified TLS with Secure browser authentication', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-tls-')) + try { + const tls = await materializeTlsFixture(root) + await withWeb(['--tls-cert', tls.certFile, '--tls-key', tls.keyFile], async (web) => { + expect(web.startup.origin).toBe(`https://127.0.0.1:${String(web.port)}`) + await expect(request('https:', '127.0.0.1', web.port, '/', web.startup.host, 'GET')) + .rejects.toThrow(/self-signed certificate/i) + const { cookie, setCookie } = await exchange( + 'https:', '127.0.0.1', web.port, `${web.startup.pathname}${web.startup.search}`, web.startup.host, tls.ca, + ) + expect(setCookie).toContain('; Secure') + const index = await request('https:', '127.0.0.1', web.port, '/', web.startup.host, 'GET', cookie, tls.ca) + expect(index.status).toBe(200) + expect(await index.text()).toContain(' { await withWeb(['--public-url', `http://gateway.example${MOUNT.slice(0, -1)}`, '--trusted-host', 'gateway.example'], async (web) => { expect(web.startup.origin).toBe('http://gateway.example') @@ -204,12 +254,12 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root', { dial: '127.0.0.1', port: web.port, prefix: '/', host: `127.0.0.1:${String(web.port)}` }, ] for (const entry of entries) { - const { cookie } = await exchange(entry.dial, entry.port, `${entry.prefix}${web.startup.search}`, entry.host) - const response = await request(entry.dial, entry.port, `${entry.prefix}api/session/create`, entry.host, 'POST', cookie) + const { cookie } = await exchange('http:', entry.dial, entry.port, `${entry.prefix}${web.startup.search}`, entry.host) + const response = await request('http:', entry.dial, entry.port, `${entry.prefix}api/session/create`, entry.host, 'POST', cookie) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ result: { ok: true } }) } - const foreign = await request('127.0.0.1', proxy.port, `${MOUNT}api/session/create`, 'evil.example', 'POST') + const foreign = await request('http:', '127.0.0.1', proxy.port, `${MOUNT}api/session/create`, 'evil.example', 'POST') expect(foreign.status).toBe(403) await foreign.arrayBuffer() } finally { @@ -218,4 +268,89 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root', expectCleanExit(await web.stop()) }) }) + + it.skipIf(NON_LOOPBACK_IPV4 === undefined)('serves the bound address and a declared authority, refusing any other name', async () => { + const trustedHost = 'dsh-direct.example' + await withWeb(['--host', NON_LOOPBACK_IPV4!, '--trusted-host', trustedHost], async (web) => { + expect(web.startup.origin).toBe(`http://${NON_LOOPBACK_IPV4!}:${String(web.port)}`) + expect(web.startup.pathname).toBe('/') + const token = web.startup.searchParams.get('token') + expect(token).not.toBeNull() + const authority = `${NON_LOOPBACK_IPV4!}:${String(web.port)}` + const { cookie, setCookie } = await exchange('http:', NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, authority) + // The carrier is plain HTTP, where a browser never returns a Secure cookie. + expect(setCookie).not.toContain('; Secure') + const authenticated = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', authority, 'POST', cookie) + expect(authenticated.status).toBe(200) + expect(await authenticated.json()).toMatchObject({ result: { ok: true } }) + + // A declared authority is admitted without being advertised; any other + // name for the same socket is a rebound Host. + const declared = `${trustedHost}:${String(web.port)}` + const declaredSession = await exchange('http:', NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, declared) + const viaDeclared = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', declared, 'POST', declaredSession.cookie) + expect(viaDeclared.status).toBe(200) + await viaDeclared.arrayBuffer() + const rebound = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', `evil.example:${String(web.port)}`, 'POST', cookie) + expect(rebound.status).toBe(403) + await rebound.arrayBuffer() + + const stopped = await web.stop() + expectCleanExit(stopped) + expect(stopped.stderr).toContain(`listening on ${NON_LOOPBACK_IPV4!} over plain HTTP`) + expect(stopped.stderr).not.toContain(token!) + }) + }) + + + it.skipIf(NON_LOOPBACK_IPV4 === undefined)('warns about the plain-HTTP bind even when an HTTPS root is advertised', async () => { + await withWeb(['--host', NON_LOOPBACK_IPV4!, '--public-url', 'https://app.example/', '--trusted-host', 'app.example'], async (web) => { + expect(web.startup.origin).toBe('https://app.example') + expect(web.startup.host).toBe('app.example') + const token = web.startup.searchParams.get('token') + expect(token).not.toBeNull() + const { cookie } = await exchange('http:', NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, 'app.example') + const authenticated = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', 'app.example', 'POST', cookie) + expect(authenticated.status).toBe(200) + expect(await authenticated.json()).toMatchObject({ result: { ok: true } }) + + const stopped = await web.stop() + expectCleanExit(stopped) + expect(stopped.stderr).toContain(`listening on ${NON_LOOPBACK_IPV4!} over plain HTTP`) + }) + }) + + it.each([ + '::ffff:127.0.0.1', + '::ffff:7f00:1', + ])('prints an IPv4 root for mapped loopback %s and serves it', async (host) => { + await withWeb(['--host', host], async (web) => { + expect(web.startup.origin).toBe(`http://127.0.0.1:${String(web.port)}`) + const { cookie } = await exchange('http:', web.startup.hostname, web.port, `/${web.startup.search}`, web.startup.host) + const authenticated = await request( + 'http:', web.startup.hostname, web.port, '/api/session/create', web.startup.host, 'POST', cookie, + ) + expect(authenticated.status).toBe(200) + expect(await authenticated.json()).toMatchObject({ result: { ok: true } }) + + const stopped = await web.stop() + expectCleanExit(stopped) + expect(stopped.stderr).not.toContain('plain HTTP') + }) + }) + + it.each(IPV6_LOOPBACKS)('prints the IPv6 loopback root for %s and serves it', async (host) => { + await withWeb(['--host', host], async (web) => { + expect(web.startup.origin).toBe(`http://[::1]:${String(web.port)}`) + const authority = `[::1]:${String(web.port)}` + const { cookie } = await exchange('http:', '::1', web.port, `/${web.startup.search}`, authority) + const authenticated = await request('http:', '::1', web.port, '/api/session/create', authority, 'POST', cookie) + expect(authenticated.status).toBe(200) + expect(await authenticated.json()).toMatchObject({ result: { ok: true } }) + + const stopped = await web.stop() + expectCleanExit(stopped) + expect(stopped.stderr).not.toContain('plain HTTP') + }) + }) }) diff --git a/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts index fa7d64a0e9..115c163d24 100644 --- a/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts @@ -296,7 +296,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () expect(result.stderr).toContain('Failed plugins (1):') expect(result.stderr).toContain(' webserver (required)\n Package: @deepseek-ai/dsh-host-webserver') expect(result.stderr).toContain('Plugins waiting for services (') - expect(result.stderr).toMatch(/connection \(required\) +webRuntime/u) + expect(result.stderr).toMatch(/web-runtime \(required\) +webServer/u) expect(result.stderr).toContain('at Server.setupListenHandle') const summary = result.stderr.split(/\n\n(?:Full diagnostics:|dsh: warning:)/u)[0]! expect(summary.match(/EADDRINUSE/gu)).toHaveLength(1) @@ -319,7 +319,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () expect(report).toContain('configurationPath:') expect(report).toContain("code: 'EADDRINUSE'") expect(report).toContain(`port: ${String(address.port)}`) - expect(report).toContain("module: '@deepseek-ai/dsh-client-connection'") + expect(report).toContain("module: '@deepseek-ai/dsh-web-app'") expect(report).toContain('at auditStartupEntries') } finally { await new Promise((resolve, reject) => { diff --git a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts index b1f89c11d1..64ab162454 100644 --- a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts @@ -59,8 +59,13 @@ function fixture() { ' }, 20)', ' ctx.effect(() => () => { clearInterval(timer) })', ' ctx.inject(["webServer", "connection"], scope => {', - ' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))', - ' scope.effect(() => () => { rmSync(config.url, { force: true }) })', + ' let disposed = false', + ' scope.effect(() => () => { disposed = true; rmSync(config.url, { force: true }) })', + // A recovery URL is a readiness signal; sibling HMR work must settle first. + ' void scope.get("loader").await().then(() => {', + ' if (disposed || !scope.get("webServer") || !scope.get("connection")) return', + ' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))', + ' })', ' })', '}', '', @@ -107,8 +112,8 @@ function fixture() { return { root, home, events, diagnostics, serverUrl, states, observer, stop, patch, watcher, render, url, config, witness, target } } -function start(f: ReturnType, extra: string[] = []) { - const child = execa(process.execPath, [bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], { +function start(f: ReturnType, extra: string[] = [], nodeArgs: string[] = []) { + const child = execa(process.execPath, [...nodeArgs, bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], { cwd: f.root, env: { ...process.env, DSH_HOME: f.home, DSH_AGENTS_HOME: join(f.root, '.agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-matrix-no-call', NODE_NO_WARNINGS: '1' }, input: '', reject: false, timeout: 110_000, killSignal: 'SIGKILL', @@ -167,6 +172,64 @@ function exit(result: { timedOut: boolean; signal?: string | undefined; exitCode } describe.skipIf(!built)('Web process failure matrix', () => { + it('exits on unreadable TLS material without opening a listener', async () => { + const f = fixture() + const missingCert = join(f.root, 'missing-cert.pem') + const listenerProbe = join(f.root, 'listen-probe.mjs') + const listening = join(f.root, 'listening') + writeFileSync(listenerProbe, [ + "import { Server } from 'node:net'", + "import { appendFileSync } from 'node:fs'", + 'const listen = Server.prototype.listen', + 'Server.prototype.listen = function (...args) {', + ` this.once('listening', () => appendFileSync(${JSON.stringify(listening)}, 'listening\\n'))`, + ' return Reflect.apply(listen, this, args)', + '}', + '', + ].join('\n')) + const app = start(f, ['--tls-cert', missingCert, '--tls-key', missingCert], ['--import', pathToFileURL(listenerProbe).href]) + try { + const result = await app.child + exit(result, 1) + expect(result.stderr).toContain(missingCert) + expect(existsSync(listening)).toBe(false) + } finally { + exit(await app.close(), 1) + } + }) + + it('rejects a non-loopback zone bind through the required Web runtime before readiness', async () => { + const f = fixture() + const host = 'fe80::1%dsh-test' + const network = join(f.root, 'zone-listen.mjs') + // Mock only the OS bind: the real WebServer retains its configured zone, + // and the shipped runtime row, Loader dependencies, and boot audit stay real. + writeFileSync(network, [ + "import { Server } from 'node:net'", + 'const listen = Server.prototype.listen', + 'Server.prototype.listen = function (...args) {', + ` if (args[1] === ${JSON.stringify(host)}) {`, + ' args[1] = "127.0.0.1"', + ' }', + ' return Reflect.apply(listen, this, args)', + '}', + '', + ].join('\n')) + const app = start(f, ['--host', host], ['--import', pathToFileURL(network).href]) + try { + const result = await app.child + exit(result, 1) + expect(result.stderr).toContain('dsh: startup failed: 1 required plugin did not activate') + expect(result.stderr).toContain(' web-runtime (required)\n Package: @deepseek-ai/dsh-web-app') + expect(result.stderr).toContain(`bind address "${host}" carries an interface zone id`) + expect(result.stderr).toContain('pass --public-url') + expect(result.stdout).not.toContain('dsh web: http') + expect(app.events()).toBe('witness apply 0\nwitness dispose 0\n') + } finally { + exit(await app.close(), 1) + } + }) + for (const required of [false, true]) { const id = required ? 'acp' : 'matrix-optional' it.each(failures)(`${required ? 'required' : 'optional'} startup %s`, async (failure, diagnostic) => { @@ -408,7 +471,7 @@ export function apply(ctx, config) { // Entry-level injection requirements are captured when the fiber is created. writeFileSync(f.patch, f.render('matrix-optional') + `- id: ${id}\n disabled: true\n`) await app.wait(() => app.state(id) === FiberState.DISPOSED) - const inject = id === 'connection' ? ['webRuntime', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency'] + const inject = id === 'connection' ? ['webStartup', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency'] const pending = f.render('matrix-optional', undefined, 2) + `- id: ${id}\n inject: ${JSON.stringify(inject)}\n` writeFileSync(f.patch, pending) await app.wait(() => app.state(id) === FiberState.PENDING && app.events().includes('witness apply 2\n')) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index f2a75bfa51..c442c84f56 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private Node-mode host process for the Electron desktop application", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "private": true, "license": "MIT", "type": "module", @@ -11,7 +11,7 @@ "lib/cli.js" ], "dependencies": { - "koffi": "^3.1.0", + "koffi": "3.1.1", "@deepseek-ai/libreoffice-kit": "^0.1.5", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh": "workspace:*", diff --git a/apps/desktop-host/src/cli.ts b/apps/desktop-host/src/cli.ts index 97a23b88a2..6c890f8990 100644 --- a/apps/desktop-host/src/cli.ts +++ b/apps/desktop-host/src/cli.ts @@ -7,7 +7,7 @@ import { installOfficeEngineResolution, runtimeArchivePath } from './office-engi /** * Run the ordinary CLI with Desktop's bundled package manager and reserved-profile plugin access. * @param runtimeDir - Prepared or ASAR-contained production DSH package tree. - * @param supportDir - Physical Desktop runtime directory containing pnpm. + * @param supportDir - Physical Desktop runtime directory containing the primary-runtime payload and Node launchers. * @returns Completion of the selected CLI command; profile plugins own their process lifetime. */ export async function runDesktopCli(runtimeDir: string, supportDir: string): Promise { @@ -16,7 +16,7 @@ export async function runDesktopCli(runtimeDir: string, supportDir: string): Pro manageDesktopProfile: true, packageManager: { command: process.execPath, - args: ['--expose-internals', join(supportDir, 'pnpm', 'bin', 'pnpm.mjs')], + args: ['--expose-internals', join(supportDir, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')], env: { ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index dd8f57c54c..3086755b0b 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -27,7 +27,7 @@ async function main(): Promise { profile: 'desktop', resolvedProfile: { profile, installAnchor }, patchFiles: [], - args: ['--no-open', '--port', '0'], + args: ['--no-open', '--host', '127.0.0.1', '--port', '0'], ...(process.argv[5] === undefined ? {} : { packageManager: { command: process.execPath, @@ -100,7 +100,7 @@ async function main(): Promise { installPlatformSessionPublisher(ctx, (session) => { if (process.connected) process.send?.({ type: 'platform-session', session }) }) - const url = ctx.connection.authenticatedUrl(`http://127.0.0.1:${String(ctx.webServer.port)}`) + const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`) if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) }) } diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts index 862f832029..02e741d4ce 100644 --- a/apps/desktop-host/tests/office.spec.ts +++ b/apps/desktop-host/tests/office.spec.ts @@ -1,6 +1,6 @@ import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -9,12 +9,14 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SkillRegistry from '@deepseek-ai/dsh-skill' import ToolRuntime from '@deepseek-ai/dsh-tools' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' import * as desktopOffice from '../src/office.ts' it('loads Desktop Office CLI paths and removes them on disposal', async () => { const root = await mkdtemp(join(tmpdir(), 'desktop-office-')) + vi.stubEnv('DSH_HOME', root) const ctx = new Context() + try { const assets = join(root, 'runtime', 'office-skills') await cp(new URL('../../../packages/skill/skill-office/assets/', import.meta.url), assets, { recursive: true }) @@ -47,10 +49,10 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => { await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } }) await ctx.loader.await() for (const entry of ctx.loader.entries()) await entry.fiber?.await() - expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office-docx', 'office-pptx', 'office-xlsx']) - expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx') }) - expect((await ctx.skills.get('office-pptx'))?.content).toContain('libreofficeKit') - expect((await ctx.skills.get('office-pptx'))?.content).toContain(JSON.stringify(nodeDirectory).slice(1, -1)) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office', 'office-docx', 'office-export-pdf', 'office-pptx', 'office-recalculate', 'office-render', 'office-xlsx']) + expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx').split(sep).join('/') }) + expect((await ctx.skills.get('office-pptx'))?.content).toContain('office-render') + expect((await ctx.skills.get('office-pptx'))?.content).not.toContain(nodeDirectory) expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies']) const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'office') expect(entry).toBeDefined() @@ -58,7 +60,10 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => { expect(await ctx.skills.list()).toEqual([]) expect(ctx.tools.schemas()).toEqual([]) } finally { - await ctx.fiber.dispose() - await rm(root, { recursive: true, force: true }) + try { await ctx.fiber.dispose() } + finally { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + } } }) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 5930130c27..345463de50 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,24 +2,36 @@ # section, a hash of its English and Chinese blocks outside code blocks and generated regions. # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md +/: + en: 0d36989eb614345d + zh: a0a1a18c07621393 /deepseek-harness-desktop: - en: 0f0f6ea914eff51e - zh: 4934245b04e9f1c0 + en: 69a6e0c6712e463b + zh: 84e1d0b09c803704 +/deepseek-harness-desktop/summary: + en: 7bc8bf30de693222 + zh: e85993d8852b6cab +/deepseek-harness-desktop/table-of-contents: + en: 05fd227f99ee535a + zh: 60d7d62bc0026973 +/deepseek-harness-desktop/application-overview: + en: e9b22f232837e131 + zh: fc5f3834b969d2d2 /deepseek-harness-desktop/terminal-command: - en: bef92953c237363f - zh: d21d6effd21b8c00 + en: 5f2d25a1d8472647 + zh: 2acb5c5d7d1917e2 /deepseek-harness-desktop/closing-the-window-and-quitting: - en: c323e02e3f0305fe - zh: 3c730db632136443 + en: 24387a07ae4eb8e7 + zh: 0119352685cf273c /deepseek-harness-desktop/key-technical-decisions: - en: 069fdfd72d2ea26a + en: 3724c53da4f7647b zh: 76ae549d43c99bb4 /deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies: - en: 0f9b6c1d6761d108 - zh: 77127063f244c361 + en: 22f1c529983660d5 + zh: e2dad2c6b41f07ce /deepseek-harness-desktop/bundled-command-runtime: - en: 09f0b75af9f0ed14 - zh: 60bbc2e67d5e87a8 + en: 372297005b57a08b + zh: 074a635ac17e4362 /deepseek-harness-desktop/installation-ownership: en: 22153a298a6662e1 zh: 3600803c8c9d0274 @@ -27,8 +39,8 @@ en: a1a47b09f4a72ba4 zh: 7b1cf27f0b72cf31 /deepseek-harness-desktop/installation-ownership/host-environment: - en: 84a9cc7ca9455aa8 - zh: 4091b8aa8231b74a + en: 98cf848762482d48 + zh: 74f9100a39ec8c2f /deepseek-harness-desktop/develop: en: fe6d23e6a3d3bc66 zh: d60c8d3a55747916 @@ -36,35 +48,14 @@ en: a51346b8d880ddba zh: 7913214c1c66063c /deepseek-harness-desktop/develop/welcome-window-appearance: - en: ae319a496639b38d - zh: ef044103240ac9da + en: 617a89aeb28131b9 + zh: 4d84bd5d86501c86 /deepseek-harness-desktop/package: - en: 4cd8a8da6977928d - zh: 9f624855a2edac37 -/deepseek-harness-desktop/package/release-versions: - en: 99607dc36badb7f1 - zh: 5df47873fe8cdea8 -/deepseek-harness-desktop/package/local-macos-dmg-without-release-signing: - en: 57869a5e3885f843 - zh: 7cfc01b2dcbebd78 -/deepseek-harness-desktop/package/local-official-bundles-from-a-loopback-registry: - en: 5f05072ec82cc62d - zh: a3d63b58d1ef2db7 -/deepseek-harness-desktop/package/runtime-file-selection: - en: e7c1eb8e78fe46e3 - zh: b7ea497dd75be524 -/deepseek-harness-desktop/package/upload-updates: - en: f48baa680e841b0d - zh: 25ded1aeaac6dad1 -/deepseek-harness-desktop/package/unsigned-windows-test-installer: - en: ed94776d1c3b7517 - zh: 9e6b1738b409d5e7 -/deepseek-harness-desktop/package/windows-installer-interface: - en: 1fc1db8f54475c8f - zh: dd834ed206070396 -/deepseek-harness-desktop/package/windows-ev-signing: - en: be1379e4b4703e14 - zh: 244ccd2874bb155a + en: ac31ebdd9de54f34 + zh: 921e534d5c4b3a16 +/deepseek-harness-desktop/windows-installer-interface: + en: 8cb4b7babbd8714d + zh: b4aebc1d2d152e01 /deepseek-harness-desktop/updates: en: 41d0f50eb7173342 zh: ce940ef3cbcc6fa5 @@ -75,14 +66,14 @@ en: 54596bef0706d305 zh: 48737302dcf3c939 /deepseek-harness-desktop/updates/local-updater-qualification: - en: bc50fd8a4d96c227 - zh: fb0a6ce80c31cda6 + en: 68ec2f8bd5763ea6 + zh: 23b932dfe8503602 /deepseek-harness-desktop/low-level-development-overrides: - en: 0b225f914142ef0b - zh: 9ffd07768d37824e + en: 8264b135871e8978 + zh: 0939aafb1b2961fc /deepseek-harness-desktop/known-limitations: - en: 5b77213c86bbcfb6 - zh: b34bf21773bb852b + en: c63d4dc7a9104954 + zh: a4c676e5b46a6ac0 /deepseek-harness-desktop/dev-note: en: 8e26b460ba31f21b zh: fbcd0307b3867125 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 531ac41368..7f516c4fab 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,16 +1,46 @@ +--- +description: "Desktop runs the Harness workspace in a native application. This reference covers application behavior, development and integration; the packaging, Windows signing and publication guides own build and release operations." +--- + # DeepSeek Harness Desktop English | [中文](README.zh.md) +## Summary + +Desktop runs the Harness workspace in a native application. This reference covers application behavior, development and integration; the packaging, Windows signing and publication guides own build and release operations. + +## Table of Contents + +- [Application overview](#application-overview) +- [Terminal command](#terminal-command) +- [Closing the window and quitting](#closing-the-window-and-quitting) +- [Key technical decisions](#key-technical-decisions) +- [Bundled command runtime](#bundled-command-runtime) +- [Installation ownership](#installation-ownership) +- [Develop](#develop) +- [Package](#package) +- [Windows installer interface](#windows-installer-interface) +- [Updates](#updates) +- [Low-level development overrides](#low-level-development-overrides) +- [Known limitations](#known-limitations) +- [Dev Note](#dev-note) + + + +## Application overview + Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited. The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. +Desktop binds `127.0.0.1`, the address Electron dials for the ready URL and the WebSocket credential filter. WebSocket streams use `ws:` or `wss:` according to the Host listener; credentials require matching authority and scheme. + The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. -Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations. +Desktop ships one pnpm distribution at `resources/runtime/primary-runtime/dependencies/pnpm`. Build-time production installation, the installed `dsh` CLI, Creator and the Web Plugin Manager execute its `bin/pnpm.mjs` with Electron Node mode; package operations do not require pnpm on PATH. The private Node launcher environment applies only to package operations. Agent workspace dependencies copy the same self-contained primary-runtime payload into the Harness home and execute pnpm with its standalone Node. Embedded Platform documents use a persistent WebContentsView partition keyed by a hash of the Platform origin and stable account ID. localStorage preferences, including dismissed notices, survive view closure and application restart; different accounts and origins use separate storage. The Host supplies the account ID from its latest successful profile read; while no ID is known, the document opens in a disposable partition that keeps no preference across opens. Opening a persistent partition clears cookies, filesystem, IndexedDB, Cache Storage, the HTTP and shader caches, service workers, and HTTP authentication before the document loads; a disposable partition clears all of its storage. Closing a view destroys the document, removes its request interceptors, and schedules the same cleanup, so authentication left by an unclean exit is cleared before the next document loads. The next open and application shutdown await that cleanup, and an update install waits for it before the installer takes over the exit. A failed cleanup rejects that open and blocks the same account until a later cleanup succeeds; other accounts keep opening. Sign-out destroys the document but retains account-scoped preferences for the next sign-in. A document already open in a disposable partition stays mounted when the account ID arrives for the same credential; the next open uses the account-scoped partition. The [storage decision](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md) describes the retention policy. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar. @@ -22,6 +52,8 @@ Desktop microphone access is restricted to audio requests from the primary `dsh- Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools. + + ## Terminal command The application menu's **Manage dsh Command…** entry, immediately below **Check for Updates…**, shows the current command and offers Install, Repair, and Remove. The command uses Desktop's installed runtime and the ordinary [dsh CLI](../cli/README.md), including when the Desktop application is closed. Open a new terminal after installation and run `dsh --version`. @@ -30,6 +62,8 @@ On macOS, installation creates `/usr/local/bin/dsh` and requests administrator a Command registration is optional after installing Desktop. Use **Manage dsh Command… → Remove** before uninstalling Desktop to remove its CLI registration; the application uninstaller does not remove it. Finish CLI commands before updating or uninstalling Desktop. The CLI runtime version follows the installed Desktop release. See [bundled command runtime](#bundled-command-runtime) for Desktop plugin commands and runtime limitations. + + ## Closing the window and quitting Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and the taskbar Close window command) hides it; Windows asks for acknowledgement before the first hide. The page and the Host keep running, tasks continue, and the next show presents the same document with its session, drafts, and scroll position; a fullscreen macOS window leaves fullscreen before hiding. The window returns through the Dock icon, a second launch, or `dsh://open` on macOS, and through the tray on Windows. Minimize is unchanged. Closing the welcome window before the workspace opens quits on Windows and, on macOS, keeps the application in the Dock without a window. @@ -44,6 +78,8 @@ The confirmation is skipped when the installer restart already confirmed task in The tray renderer enlarges the whale by 20% around the tile center while preserving the background and aspect ratio; application and installer icons retain their original proportions. + + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. @@ -54,21 +90,25 @@ The macOS File menu displays the accepted single-key binding, including arrow ke The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release. + + ### Bundled workspace dependencies electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`, so the Electron main bundle `lib/main.js` inlines its workspace devDependencies and leaves `electron`, Node builtins, and those `dependencies` as its only bare imports; a sandboxed preload may leave only `electron`, `events`, `timers`, and `url`, the modules its `require` polyfill resolves. The main bundle resolves the inlined packages from their `lib/` output, so the root `build:lib:host` bundles `apps/desktop` after the concurrent workspace tsdown pass, and [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) fails any Desktop bundle whose static, dynamic, or `require()` import the packaged application cannot resolve. Without that check an import rolldown could not resolve ships as an external specifier and fails at launch with `ERR_MODULE_NOT_FOUND`. The [archived bundle-order decision](../../.agents/notes/archived/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) records the alternatives. -Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. +Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the [bounded retry policy](windows-signing/README.md#windows-ev-signing). Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. -Desktop registers `office-docx`, `office-pptx`, and `office-xlsx` by default. The skills use the bundled Python libraries for creation and focused edits, then reopen the files and run a shared structural checker before delivery. PowerPoint creation and editing use python-pptx. Skill resources are copied to `runtime/office-skills` outside ASAR so Python can read the checker. An available `render_document` tool can add visual inspection; its absence does not prevent authoring or delivery. See the [Office skill package](../../packages/skill/skill-office/README.md) for checks and limitations. +Desktop exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries and the shared structural checker; operation skills invoke the configured Node and CLI paths. Skills live in `runtime/office-skills` outside ASAR so Python can read resources. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations. The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running. The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. macOS signs the versioned Python executable once, retaining the [two interpreter aliases](../../packages/skill/tool-workspace-dependencies/README.md) in external resources and installed copies. POSIX native-target checks require relative interpreter aliases. All native-target checks exercise command names, child execution and a pip-enabled venv, verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. + +The shared Desktop/SDK runtime builder omits directories named `tests` beneath NumPy and pandas before copying or signing the payload, and removes their rows from each distribution’s `RECORD`. It retains `numpy.testing`, `pandas.testing`, `pandas._testing`, native extensions and licenses. Complete upstream suites are unavailable; native checks verify the retained testing APIs, absence of test directories and existence of recorded files. | Decision | Why | Direct consequence | |---|---|---| @@ -94,6 +134,8 @@ To manage Desktop's plugins, launch Desktop once to initialize its profile, full Finish CLI commands before updating or uninstalling Desktop; the installer does not coordinate with running commands. The CLI does not supply Office authoring-skill resource paths. Desktop's existing Office setup and Web attachment conversion retain their own resource configuration. `prepare:runtime` copies the launch scripts without compiling a native CLI launcher. + + ## Installation ownership Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot this profile. The Desktop-installed command can manage its plugins while the application is quit; npm-installed dsh cannot mutate it. @@ -140,6 +182,8 @@ Recovery waits for Host shutdown before changing plugin activation. The native r macOS and Linux GUI launches inherit only the session manager's environment, without variables exported by shell startup files. Before the first Host starts, Desktop runs the account's login shell (from the user database, not `$SHELL`) once as ` -ilc` and reads its `env -0` output between delimiters, so `~/.zprofile` and `~/.zshrc` (or the shell's equivalents) take effect for the Host, agent shells, terminals, and profile configuration. Reading runs in parallel with profile preparation. The probe has no terminal input and sets `DISABLE_AUTO_UPDATE=true`, `ZSH_TMUX_AUTOSTARTED=true`, and `ZSH_TMUX_AUTOSTART=false` to keep oh-my-zsh and tmux plugins from blocking. Shell values replace inherited values except `PWD`, `OLDPWD`, `SHLVL`, `_`, those probe variables, and launcher-owned `DSH_*` and `ELECTRON_*` names; Desktop resolves paths such as `DSH_HOME` before the read, so the Host keeps the same values. The read completes at the closing delimiter, so background processes started by startup files may keep running with their output discarded. A candidate that fails to start, exits nonzero, prints no delimited output, or exceeds `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` (integer milliseconds from 1000 through 2147483647, default `10000`; its process group is killed) is logged as a warning, and Desktop tries `/bin/zsh`, `/bin/bash`, and `/bin/sh` in turn; when all fail the Host uses the inherited environment. The dump command uses POSIX syntax, so an account shell such as csh, tcsh, or nushell fails and the Host receives the environment from the first system shell's startup files instead. Quitting Desktop during the read kills the running probe's process group. The result is read once per application process, so changes to shell startup files apply after quitting and reopening Desktop. Windows GUI launches already inherit the user and system environment variables from the registry, so Windows skips this step. + + ## Develop The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files. @@ -180,256 +224,44 @@ The browser-login waiting page offers a copy-link action for the current pending The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 entry layout and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions. + + ## Package - -### Release versions - -Before each Desktop packaging run, first confirm the complete version string with the current user. Check the selected deployment, dsh base version, retained release records, and published objects, then propose the exact version for approval. Do not start packaging until the user confirms that version; the deployment setting alone does not authorize a version choice. - -Record the current dsh version as the base. A production Desktop release uses that exact version, including any `alpha`, `beta`, or `rc` identifiers. A test release preserves the complete prerelease base and appends `.YYYYMMDD.index`; a stable base uses `-test.YYYYMMDD.index` instead. - -| dsh base | Production Desktop | Test Desktop example | -|---|---|---| -| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | -| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | -| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | -| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | - -Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Test distribution does not publish the corresponding unsuffixed base. - -Pass the confirmed version to the packaging command as `--build-version`, which reaches the artifact names, the update feed, and the upload validation as one value. The manifests keep the product version, so a test build no longer rewrites the release family and leaves nothing to revert: - -```sh -pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 -``` - -`--build-version auto` proposes the next index for today, reading the published objects in the destination bucket and falling back to this target's local output directory when no bucket is configured or the listing cannot finish within its deadline. Confirm the proposal it prints before an upload; a run script forwards `--` on its own, and the packaging entry accepts it either way. - -A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. - -Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier. - -Except for local unsigned macOS builds, packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging. - -Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. `--build-version auto` does contact the destination bucket, including under `--check`. Run the same checks separately: - -```sh -pnpm --dir apps/desktop run check:package -``` - -`prepare:desktop` is not a prerequisite: - -```sh -pnpm run package:desktop -``` - -Release automation uses fixed target commands so runtime preparation, dsh preparation, and electron-builder receive the same platform and architecture: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run package:desktop:mac:x64 -pnpm run package:desktop:win:x64 -``` - -The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target. - -Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Electron archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. - -### Local macOS DMG without release signing - -After `pnpm install`, run one command from the repository root. No `.env.macos`, Apple account, certificate, or update service configuration is required: - -```sh -pnpm run package:desktop:mac:arm64:unsigned -pnpm run package:desktop:mac:x64:unsigned -``` - -Add `--check` to validate local configuration and tools without building, or `--dir` to stop at the unpacked application. The DMG is `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`. After all checks pass, the command prints `DMG:` followed by its absolute path. Open it and copy the application to a local test directory. - -These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. - -### Local Official bundles from a loopback registry - -The test DMG excludes the provider runtimes the Official on-demand catalog advertises, and a release publishes matching bundle versions to npm. To install locally built bundles before that release, start a loopback registry that packs and serves the catalog's complete local dependency closure: - -```sh -pnpm run dev:bundle-registry -``` - -The command validates the catalog and the workspace inventory, runs `pnpm run build:official`, packs the closure into a new `dist/test-bundles//` directory, writes `bundle-registry.json` last as the completion marker, and only then prints the artifact path, the registry URL, and the exact `name@version` specs. It serves `127.0.0.1` on an allocated port under a fresh namespace until Ctrl+C or SIGTERM, and stopping it leaves both the artifacts and the installed packages on disk. It needs no `.env.macos`, Apple credentials, or npm credentials, and it does not change `pnpm run package:desktop:unsigned`. - -In the packaged application, open Plugins → Add plugin, paste one printed spec, choose **Custom address**, paste the printed registry URL, and install. A custom registry is asked alone, so this process must answer every dependency: repository-owned packages come from the run's own archives, and third-party packages keep npm's real metadata and bytes through a fixed redirect to `registry.npmjs.org`, which still requires network access. The installed entry stays Official because its package name is in the catalog. The registry choice is remembered in this browser; choose an offered registry again to switch back. - -A run's artifacts are immutable, so a rebuild serves a new namespace and a new URL, and the Official **Update** control does not appear while the installed and running versions match. The profile records the previous run's tarball URL in `pnpm-lock.yaml`, and pnpm verifies recorded entries, so installing or reinstalling that bundle against a new URL is refused with `ERR_PNPM_TARBALL_URL_MISMATCH` and can leave the earlier files in place. To test changed code at the same version, clear the stale resolution first — `pnpm clean --lockfile` in `$DSH_HOME/profiles/`, or install into a fresh profile — then install again from the new URL. `bundle-registry.json` records each archive's version, filename, size, and SHA-512 integrity together with the packing checkout's commit and dirty flag. - -The served `time` is the real packing time, so pnpm 11's default 24-hour `minimumReleaseAge` records the exemption it granted in the profile's `pnpm-workspace.yaml` (`minimumReleaseAgeExclude`); an explicitly stricter policy there fails the installation with pnpm's own message instead of being overridden. A `@scope:registry` route in `~/.npmrc` or the profile's `.npmrc` overrides `--registry` for that scope, so it must not route the served scope. - -### Runtime file selection - -Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. - -The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). - -The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. - -Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. - -```powershell -pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll -``` - -The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. - -When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\-updater\installer-logs\extract-failure-.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. - + + + + + + -### Upload updates +Use the dedicated guides for build and release operations. Shared Windows runners have accepted credential and device isolation limits; read them before signing or executing branch code. -Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. - -Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. - -Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. - -`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. - -The update destination and upload credentials follow the selected deployment: - -| Environment | Public origin | COS bucket | COS credentials | -|---|---|---|---| -| `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | -| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | - -Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. To test an upgrade between versions, reuse the installed client’s ID for the later version. Production does not use this field. - -```sh -node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" -``` - -Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Format validation cannot establish randomness; use the generator output. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. - -Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run upload:mac:arm64 -``` - -The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. - -Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. - -The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. - -macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. - -macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache`. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. - -Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. - -App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log. - -Mac packaging reads three tuning fields from `.env.macos`: - -| Setting | Default | Scope | -|---|---|---| -| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. | -| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. | -| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. | - -The proxy fields are independent and reject URL credentials, paths, queries and fragments. Empty values preserve inherited networking. An explicit download proxy replaces child-process proxy variables and bypasses local hosts only; it does not change system settings. Windows and standalone `release:pack` retain their existing concurrency defaults. Company proxy addresses belong in the ignored local file; see internal documentation for addresses. - -Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. The lock loads `@deepseek-ai/node-addon-system/flock` at first use rather than at script start, so `check:package` and the packaging entry load on a checkout that has not built `native/system`; when the host addon binary or the entry's JavaScript is missing at lock time, the loader runs `pnpm run build:native-system` and `pnpm --dir native/system run build:ts` before locking, so recovery works on such a checkout as well. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy. - -### Unsigned Windows test installer - -On Windows x64, use the complete unsigned packaging command for local installation testing: - -```sh -pnpm run package:desktop:win:x64:unsigned -``` - -The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, including Python and Visual C++ build tools for native modules. Set `PYTHON` to the Python executable when it is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits automatic-update configuration, strips signing credentials, and creates no release completion record. It does not require EV credentials or an update origin. The signed packaging and upload commands retain their release requirements. +- [Desktop packaging](packaging/README.md): local and Actions builds, versions, deployment settings, artifacts, logs and verification. +- [Windows signing and runner operations](windows-signing/README.md): machine setup, accepted isolation risks, signing credentials, token failures and cache maintenance. +- [Desktop publication](publishing/README.md): upload credentials, release identity, fixed downloads, update feeds and upload recovery. -### Windows installer interface + + +## Windows installer interface The Windows installer uses native NSIS pages with light and dark palettes, system shadows, an editable installation directory, and a finish page whose launch checkbox is selected by default. Installation is restricted to the current user. Clicking Install or pressing Enter validates the current path; drive roots remain invalid after normalization, including repeated trailing separators. New destinations must be empty, and nonempty destinations must be registered installations. Running executables at the affected installation path produce a native prompt and remain running; same-named applications in other directories do not block installation. Silent updates wait up to ten seconds for the affected application to exit, then stop with exit code 2 if it is still running. -The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the installation flow described above. First-launch profile preparation remains a separate Desktop operation. +The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the [installation checks](packaging/README.md#runtime-file-selection). First-launch profile preparation remains a separate Desktop operation. Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform. -Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the Windows EV configuration below to sign test executables and the helper before embedding them; it does not enable an update feed. +Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed. The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only. Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` to compile isolated English and Chinese fixtures with a per-run scoped package name. Omit `--compile-only` to run the native remover regression and the interactive, silent, `--updated`, `/KEEP_APP_DATA` and `DSH_HOME`-inside-Electron-data checks against seeded data. Compilation does not establish installed-uninstall behavior. -### Windows EV signing - -Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits. - -Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. - -Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required. - -Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. - -Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. - -Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. - -Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. - -Signed Windows configuration derives the updater's `publisherName` from the same public certificate's `CN`, `O`, and `C` attributes. Each must be present, nonempty, and single-valued. These identity attributes allow certificate renewal without pinning a leaf thumbprint. The installed application's `app-update.yml` carries the expected publisher; the downloaded feed does not choose it. Unsigned test builds omit updater configuration. See the [signature qualification record](tests/README.md) for real-file verification and its limits. - -For this project's SafeNet token, `SignTool Error: No private key is available.` indicates an incorrect PIN. Stop all signing attempts immediately and wait for the user to correct the PIN before continuing. Five incorrect PIN attempts lock the token. Do not retry packaging or signing probes after this error. The signer serializes token operations and rejects all queued tasks after the first failure. - -Windows package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `.desktop-build/packaging-runs/`. Each run retains `run.json`, timestamped `events.jsonl`, redacted `stdout.log` and `stderr.log`, and `result.json`. A signing failure also writes `fatal.json` and notifies the parent through stderr; the supervisor immediately requests termination of its stage process tree and waits for exit. Failed stages cannot start subsequent stages or create a release completion record. Journal failures also stop the run. Termination errors remain failures and require operator inspection; a missing final record means completion was not established. - -Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing followed by verification of the configured primary signature releases that file; timestamps are completed afterward without hardware access. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock. - -Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. - -NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. - -Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certificate), `DSH_DESKTOP_WINDOWS_SIGNTOOL` (SafeNet-compatible SignTool), `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` (matching private-key container), and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` (Token Password). The private key stays on the USB token; keep the certificate and local credential file out of Git. - -```sh -pnpm run package:desktop:win:x64 -``` - -Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. - -Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md). - -The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. - -Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: - -```sh -pnpm run package:desktop:dir -pnpm run package:desktop:mac:arm64:dir -``` - -To inspect or troubleshoot the prepared host-target resources without invoking electron-builder, stop the same pipeline after preparation: - -```sh -pnpm run prepare:desktop -``` - -This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content. - -Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. - -Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload. - -An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine. + ## Updates @@ -514,10 +346,14 @@ The command builds the Desktop shell and runs its coordinator with real Electron The downloaded bytes are inert, and the installation call is recorded rather than executed. The test substitutes browser opening and clipboard writing to avoid external navigation and clipboard changes. It does not boot the full product workspace, qualify a real installer or restart, verify publisher signatures, or exercise differential updates or macOS. Stalled policy requests, feed requests, and payload transfers exercise real deadlines and recovery. The actual ordinary dialog verifies isolated preload loading, card geometry, an unfiltered parent page, cancellation, task-warning choices, and explicit installation approval; account-row component tests provide separate evidence. The [local qualification decision](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md) and [verification record](tests/README.md) preserve these limits; production release requirements remain unchanged. + + ## Low-level development overrides An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` are optional overrides with application-path defaults. `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` is required for every unpackaged launch: the development launchers (`dev:desktop`, `start:desktop`, and the workspace-update qualification runner) set it to the primary-runtime directory of the target they prepared, and a launch without it fails with the fatal startup dialog. The launcher must set it because the shell cannot derive that directory from `process.arch`: the build target fixes Windows to x64 while the host may be arm64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. + + ## Known limitations - Account sign-in is not connected; the Sign in button is disabled. Windows material rendering still requires platform QA. @@ -541,6 +377,8 @@ Private Platform deployment headers are injected by the embedded browser session The account provider’s `embeddedPageDist` configuration adds a `dist` query parameter to embedded Usage and Top-up URLs. Its default is empty; private frontend branch selectors belong in the local profile patch. It does not change API URLs or credential delivery. + + ## Dev Note Pre-launch CDN and capacity decisions are tracked in the [Desktop update proposal](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md#cdn-and-capacity-qualification). diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 097aa15d4e..5ef7451bd6 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -1,16 +1,46 @@ +--- +description: "Desktop 在原生应用中运行 Harness 工作区。本参考文档说明应用行为、开发与集成;打包、Windows 签名和发布指南分别维护构建与发布操作。" +--- + # DeepSeek Harness 桌面端 [English](README.md) | 中文 +## 概述 + +Desktop 在原生应用中运行 Harness 工作区。本参考文档说明应用行为、开发与集成;打包、Windows 签名和发布指南分别维护构建与发布操作。 + +## 目录 + +- [应用概览](#application-overview) +- [终端命令](#terminal-command) +- [关闭窗口与退出](#closing-the-window-and-quitting) +- [关键技术决策](#key-technical-decisions) +- [内置命令运行时](#bundled-command-runtime) +- [安装归属](#installation-ownership) +- [开发](#develop) +- [打包](#package) +- [Windows 安装界面](#windows-installer-interface) +- [更新](#updates) +- [底层开发覆盖项](#low-level-development-overrides) +- [已知限制](#known-limitations) +- [开发备注](#dev-note) + + + +## 应用概览 + 桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。 +Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过滤器拨号的地址。WebSocket 流按 Host 监听器使用 `ws:` 或 `wss:`;附加凭据要求 authority 与 scheme 均匹配。 + 应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 -Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。 +Desktop 在 `resources/runtime/primary-runtime/dependencies/pnpm` 中携带一份 pnpm 分发包。构建时的生产依赖安装、安装版 `dsh` CLI、Creator 和 Web 插件管理器通过 Electron Node 模式执行其中的 `bin/pnpm.mjs`;包操作不要求 PATH 上存在 pnpm。私有 Node 启动器环境仅用于包操作。agent(智能体)的工作区依赖将同一份完整 primary-runtime 复制到 Harness 主目录,并使用其中的独立 Node 执行 pnpm。 Platform 内嵌文档使用持久化 WebContentsView 分区,分区名由 Platform 来源和稳定账号 ID 的哈希决定。localStorage 中的页面偏好(包括已关闭的通知)在关闭视图和重启应用后保留;不同账号和来源使用独立存储。账号 ID 来自 Host 最近一次成功的资料读取;尚无该 ID 时,文档在一次性分区中打开,该分区不跨次保留偏好。打开持久分区会先清理 Cookie、文件系统、IndexedDB、Cache Storage、HTTP 与着色器缓存、Service Worker 及 HTTP 认证状态;一次性分区则清理其全部存储。关闭视图会销毁文档、移除请求拦截器并安排相同的清理,因此异常退出遗留的认证会在下一个文档加载前被清除。下次打开和应用退出都会等待该清理完成,更新安装也会在安装器接管退出前等待。清理失败会使该次打开失败,并在后续清理成功前阻止同一账号打开;其他账号不受影响。退出登录会销毁文档,但保留账号偏好供下次登录使用。同一凭证下账号 ID 迟到时,已以一次性分区打开的文档保持挂载;下次打开使用账号分区。[存储决策](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md)说明保留策略。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。 @@ -22,6 +52,8 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform 按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。 + + ## 终端命令 应用菜单中的**管理 dsh 命令…**位于**检查更新…**下方,显示当前命令,并提供安装、修复和移除操作。命令复用 Desktop 已安装的运行时和普通 [dsh CLI](../cli/README.zh.md),Desktop 应用关闭后也可以使用。安装后打开新终端,运行 `dsh --version`。 @@ -30,6 +62,8 @@ macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会 命令注册是安装 Desktop 后的可选操作。卸载 Desktop 前,请通过**管理 dsh 命令… → 移除**删除其 CLI 注册;应用卸载程序不会移除该注册。更新或卸载 Desktop 前请结束 CLI 命令。CLI 运行时版本随已安装的 Desktop 版本变化。Desktop 插件命令与运行时限制见[内置命令运行时](#bundled-command-runtime)。 + + ## 关闭窗口与退出 关闭主窗口(macOS 的关闭按钮和 ⌘W;Windows 的 ×、Alt+F4 和任务栏"关闭窗口")会隐藏窗口;Windows 首次隐藏前需要确认。页面和 Host 继续运行,任务不受影响,下次显示时仍是原来的文档,会话、草稿和滚动位置都保留;macOS 全屏窗口先退出全屏再隐藏。macOS 通过 Dock 图标、再次启动或 `dsh://open` 找回窗口,Windows 通过托盘找回。最小化行为不变。进入工作区前关闭欢迎窗口,Windows 上走退出流程,macOS 上应用留在 Dock 中且没有窗口。 @@ -44,6 +78,8 @@ Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单 托盘渲染器以底板中心为基准将鲸鱼放大 20%,保留背景和宽高比;应用和安装器图标保持原有比例。 + + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 @@ -60,17 +96,19 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含 electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`,因此 Electron 主进程 bundle `lib/main.js` 内联其工作区 devDependencies,裸导入只剩 `electron`、Node 内置模块与这些 `dependencies`;沙箱 preload 只能留下 `electron`、`events`、`timers` 与 `url`,即其 `require` polyfill 能解析的模块。主进程 bundle 从被内联包的 `lib/` 产物解析它们,所以根 `build:lib:host` 在并发的工作区 tsdown 阶段之后才为 `apps/desktop` 打 bundle,并由 [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) 让任何静态、动态或 `require()` 导入无法在打包应用内解析的 Desktop bundle 直接失败。没有这项检查时,rolldown 无法解析的导入会作为外部说明符进入产物,并在启动时以 `ERR_MODULE_NOT_FOUND` 失败。[已归档的bundle 顺序决策](../../.agents/notes/archived/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md)记录了备选方案。 -Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 +Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循[有界重试规则](windows-signing/README.zh.md#windows-ev-signing)。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 -Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技能使用内置 Python 库创建文件和进行定点编辑,随后重新打开文件,并在交付前运行共享结构检查器。PowerPoint 的创建和编辑使用 python-pptx。技能资源复制到 ASAR 外的 `runtime/office-skills`,让 Python 可以读取检查器。可用的 `render_document` 工具可以补充视觉检查;缺少该工具不妨碍创作或交付。检查范围与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。 +Desktop 展示精简的 `office` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库和共享结构检查器;操作技能调用配置的 Node 和 CLI 路径。技能位于 ASAR 外的 `runtime/office-skills`,便于 Python 读取资源。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。 该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。macOS 只对版本化 Python 可执行文件签名一次,在外部资源及安装副本中保留[两个解释器别名](../../packages/skill/tool-workspace-dependencies/README.zh.md)。POSIX 本机目标检查要求解释器别名为相对链接。所有本机目标检查验证命令名、子进程执行及包含 pip 的 venv,并在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 + +Desktop/SDK 共享运行时构建器在复制或签名产物前,裁剪 NumPy 和 pandas 下名为 `tests` 的目录,并移除各分发包 `RECORD` 中对应的记录。它保留 `numpy.testing`、`pandas.testing`、`pandas._testing`、原生扩展和许可证。完整上游测试套件不可用;本机检查验证保留的 testing API、测试目录已移除,以及清单所列文件存在。 | 决策 | 原因 | 直接结果 | |---|---|---| @@ -96,6 +134,8 @@ Welcome 加载共享 Toast 的配色和阴影变量,挂载在 body 下的通 更新或卸载 Desktop 前请结束 CLI 命令;安装程序不与运行中的命令协调。CLI 不提供 Office 创作 skill 的资源路径。Desktop 现有 Office 设置与 Web 附件转换保留各自的资源配置。`prepare:runtime` 复制启动脚本,无需编译原生 CLI 启动器。 + + ## 安装归属 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动此 profile。Desktop 内置命令可在应用退出后管理其插件;npm 安装的 dsh 不能修改它。 @@ -142,6 +182,8 @@ macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏 macOS 和 Linux 从图形界面启动的程序只继承会话管理器提供的环境,不包含 shell 启动文件导出的变量。第一个 Host 启动之前,Desktop 以 ` -ilc` 运行一次账户的登录 shell(取自用户数据库,不看 `$SHELL`),读取定界符之间的 `env -0` 输出,使 `~/.zprofile` 和 `~/.zshrc`(或该 shell 的对应文件)对 Host、agent shell、终端和 profile 配置生效。读取与 profile 准备并行进行。读取进程没有终端输入,并设置 `DISABLE_AUTO_UPDATE=true`、`ZSH_TMUX_AUTOSTARTED=true` 和 `ZSH_TMUX_AUTOSTART=false`,避免 oh-my-zsh 和 tmux 插件阻塞。shell 的值覆盖继承的值,但 `PWD`、`OLDPWD`、`SHLVL`、`_`、上述读取变量以及启动方自有的 `DSH_*` 和 `ELECTRON_*` 除外;Desktop 在读取之前已按 `DSH_HOME` 等变量解析路径,因此 Host 保持相同的值。读取在结束定界符出现时完成,因此启动文件启动的后台进程可以继续运行,其输出被丢弃。候选 shell 无法启动、以非零状态退出、没有输出定界内容或超过 `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS`(1000 到 2147483647 的整数毫秒,默认 `10000`;超时会结束其进程组)时,Desktop 记录一条警告,并依次尝试 `/bin/zsh`、`/bin/bash` 和 `/bin/sh`;全部失败时 Host 使用继承的环境。读取命令使用 POSIX 语法,因此 csh、tcsh 或 nushell 等账户 shell 会失败,Host 改为获得第一个系统 shell 的启动文件所设置的环境。读取期间退出 Desktop 会结束正在运行的读取进程组。每个应用进程只读取一次,因此修改 shell 启动文件后需要退出并重新打开 Desktop。Windows 从图形界面启动的程序已经从注册表继承用户和系统环境变量,因此 Windows 跳过这一步。 + + ## 开发 开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。 @@ -182,256 +224,44 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否 欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的入口布局和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。 + + ## 打包 - -### 发布版本 - -每次 Desktop 打包前,第一步都要与当前用户确认完整版本号。检查所选部署环境、dsh 基础版本、保留的发布记录和已发布对象,再提出准确版本供用户确认。用户确认前不得启动打包;仅选择部署环境不代表用户已认可版本号。 - -记录当前 dsh 版本作为基础版本。production Desktop 使用完全相同的版本,包括其中的 `alpha`、`beta` 或 `rc` 标识。test 发布保留完整的预发布基础版本并追加 `.YYYYMMDD.index`;稳定基础版本则追加 `-test.YYYYMMDD.index`。 - -| dsh 基础版本 | production Desktop | test Desktop 示例 | -|---|---|---| -| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | -| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | -| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | -| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | - -日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。test 分发不发布对应的无后缀基础版本。 - -把确认后的版本通过 `--build-version` 传给打包命令,该值同时决定产物文件名、更新 feed 与上传校验。清单保留产品版本,因此 test 打包不再改写发布家族,也不留下需要还原的改动: - -```sh -pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 -``` - -`--build-version auto` 会给出当天的下一个序号:读取目标 bucket 中已发布的对象,未配置 bucket 或列举未能在期限内完成时回退到本目标的本地输出目录。上传前请确认它打印的版本号;run script 会自行透传 `--`,打包入口两种写法都接受。 - -production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 - -版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。 - -除本地 macOS 未签名构建外,打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。 - -每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查: - -```sh -pnpm --dir apps/desktop run check:package -``` - -无需提前执行 `prepare:desktop`: - -```sh -pnpm run package:desktop -``` - -发布自动化使用固定目标命令,确保运行时准备、dsh 准备与 electron-builder 接收相同的平台和架构: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run package:desktop:mac:x64 -pnpm run package:desktop:win:x64 -``` - -macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Linux 不是受支持的 Desktop 发布目标。 - -每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Electron 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 - -### 无发布签名的本地 macOS DMG - -执行 `pnpm install` 后,在仓库根目录运行一条命令即可,无需 `.env.macos`、Apple 账号、证书或更新服务配置: - -```sh -pnpm run package:desktop:mac:arm64:unsigned -pnpm run package:desktop:mac:x64:unsigned -``` - -添加 `--check` 仅验证本地配置和工具而不构建,或添加 `--dir` 只生成解包应用。DMG 路径为 `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`。所有检查通过后,命令打印 `DMG:` 及其绝对路径。打开后将应用复制到本地测试目录。 - -这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。 - -### 用回环 registry 安装本地 Official 包 - -测试 DMG 不包含 Official 按需目录所宣传的 provider 运行时,而正式发布才会把对应版本的 bundle 发到 npm。要在发布前安装本地构建的 bundle,请启动一个回环 registry,它会打包并提供该目录的完整本地依赖闭包: - -```sh -pnpm run dev:bundle-registry -``` - -该命令先校验目录与 workspace 清单,运行 `pnpm run build:official`,把闭包打包进新的 `dist/test-bundles//` 目录,最后写入 `bundle-registry.json` 作为完成标记,然后才打印产物路径、registry URL 和精确的 `name@version` 规格。它在分配到的端口上只监听 `127.0.0.1`,使用一次性的命名空间提供服务,直到 Ctrl+C 或 SIGTERM;停止后产物和已安装的包都保留在磁盘上。它不需要 `.env.macos`、Apple 凭据或 npm 凭据,也不改变 `pnpm run package:desktop:unsigned`。 - -在打包应用中打开“插件 → 添加插件”,粘贴一条打印出的规格,选择**自定义地址**,粘贴打印出的 registry URL 后安装。自定义 registry 会被单独询问,因此必须由本进程回答每一个依赖:仓库自有的包取自本次运行的产物,第三方包则通过固定重定向到 `registry.npmjs.org` 保留 npm 的真实元数据与字节,因此仍需要网络访问。安装后的条目仍归类为 Official,因为它的包名在目录中。registry 选择会在此浏览器中记住;再次选择某个提供的 registry 即可切回。 - -一次运行的产物是不可变的,所以重新构建会提供新的命名空间和新 URL;当已安装版本与运行版本一致时,Official 的**更新**控件不会出现。profile 会在 `pnpm-lock.yaml` 中记录上一次运行的 tarball URL,而 pnpm 会校验已记录的条目,因此用新 URL 再次安装或重装该 bundle 会被 `ERR_PNPM_TARBALL_URL_MISMATCH` 拒绝,并且可能继续保留旧文件。要在同一版本上验证改动后的代码,请先清除过期的解析结果——在 `$DSH_HOME/profiles/` 中运行 `pnpm clean --lockfile`,或改用全新的 profile——然后再从新 URL 安装。`bundle-registry.json` 记录每个归档的版本、文件名、大小和 SHA-512 完整性,以及打包所用 checkout 的 commit 和 dirty 标记。 - -所提供的 `time` 是真实的打包时间,因此 pnpm 11 默认的 24 小时 `minimumReleaseAge` 会在 profile 的 `pnpm-workspace.yaml` 中记录它授予的豁免(`minimumReleaseAgeExclude`);如果那里显式配置了更严格的策略,安装会以 pnpm 自己的报错失败,而不会被覆盖。`~/.npmrc` 或 profile 的 `.npmrc` 中的 `@scope:registry` 路由会覆盖该 scope 的 `--registry`,因此不能让它们指向正在提供服务的 scope。 - -### 运行时文件筛选 - -Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 - -[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 - -打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 - -Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 - -```powershell -pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll -``` - -Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 - -解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 - + + + + + + -### 上传更新 +构建与发布操作请使用独立指南。共享 Windows runner 存在已接受的凭据和设备隔离限制;签名或执行分支代码前必须阅读。 -固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 - -test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 - -Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 - -`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 - -更新目标与上传凭据都与所选环境对应: - -| 环境 | 公开 origin | COS bucket | COS 凭据 | -|---|---|---|---| -| `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | -| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | - -每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。需要验证跨版本升级时,后续版本沿用已安装客户端的 ID。生产环境不使用该字段。 - -```sh -node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" -``` - -通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。格式校验无法判断随机性,请使用生成器的输出。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 - -在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run upload:mac:arm64 -``` - -内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 - -前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 - -macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 - -macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 - -macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 - -Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 - -App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 - -Mac 打包从 `.env.macos` 读取三个调优字段: - -| 设置 | 默认值 | 作用范围 | -|---|---|---| -| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 | -| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 | -| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 | - -两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。 - -Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。该锁在首次使用时才加载 `@deepseek-ai/node-addon-system/flock`,而不是在脚本启动时加载,因此 `check:package` 和打包入口在未构建 `native/system` 的 checkout 上也能加载;加锁时若宿主 addon 二进制或入口的 JavaScript 缺失,加载器会先运行 `pnpm run build:native-system` 和 `pnpm --dir native/system run build:ts` 再加锁,因此恢复命令在这样的 checkout 上同样可用。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。 - -### 未签名 Windows 测试安装包 - -在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试: - -```sh -pnpm run package:desktop:win:x64:unsigned -``` - -该命令要求设置 `DSH_DESKTOP_APP_ID` 并具备常规构建依赖,包括编译原生模块所需的 Python 和 Visual C++ 构建工具。Python 不在 `PATH` 中时,将 `PYTHON` 设置为其可执行文件路径。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略自动更新配置,清除签名凭据,且不生成发布完成记录。它不需要 EV 凭据或更新源地址。签名打包和上传命令仍遵循正式发布要求。 +- [Desktop 打包](packaging/README.zh.md):本地与 Actions 构建、版本、部署配置、产物、日志和验收。 +- [Windows 签名与 runner 运维](windows-signing/README.zh.md):机器部署、已接受的隔离风险、签名凭据、Token 失败与缓存维护。 +- [Desktop 发布](publishing/README.zh.md):上传凭据、发布标识、固定下载、更新清单和上传恢复。 -### Windows 安装界面 + + +## Windows 安装界面 Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。点击安装或按 Enter 均校验当前路径;路径规范化后仍拒绝磁盘根目录,包括带重复末尾分隔符的写法。新安装位置必须为空,非空位置必须是已登记的安装目录。受影响安装路径中的程序运行时显示系统提示,并保持应用运行;其他目录中的同名应用不阻止安装。静默更新最多等待受影响应用退出十秒,若仍在运行则以退出码 2 结束。 -主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循上文描述的安装流程。首次启动的配置档案准备仍属于独立的 Desktop 操作。 +主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循[安装检查](packaging/README.zh.md#runtime-file-selection)。首次启动的配置档案准备仍属于独立的 Desktop 操作。 Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。 -在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用下文的 Windows EV 配置,在嵌入前对测试程序和辅助库签名;它不会启用更新源。 +在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。 Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。 使用 `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` 以每次运行唯一的带作用域包名编译独立的中英文夹具。省略 `--compile-only` 可对预置数据运行原生删除器回归,以及交互、静默、`--updated`、`/KEEP_APP_DATA` 和 `DSH_HOME` 位于 Electron 数据内的检查。编译本身不能证明已安装卸载行为。 -### Windows EV 签名 - -运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。 - -预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 - -缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 - -每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 - -使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 - -Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 - -Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 - -签名 Windows 配置从同一份公开证书的 `CN`、`O` 和 `C` 属性生成 updater 的 `publisherName`。每个属性都必须存在、非空且只有一个值。这些身份属性允许证书续期,无需固定叶证书指纹。已安装应用的 `app-update.yml` 保存预期发布者,下载的清单不能选择该身份。未签名测试构建省略 updater 配置。真实文件验证及其限制见[签名验收记录](tests/README.zh.md)。 - -本项目使用的 SafeNet Token 出现 `SignTool Error: No private key is available.` 时,说明 PIN(密码)错误。立即停止所有签名尝试,等待用户处理 PIN 后再继续。PIN 输错达到五次会锁定 Token。遇到该错误后,不得重试打包或签名探针。签名器串行执行 Token 操作,首次失败后拒绝所有排队任务。 - -Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/packaging-runs/` 下的唯一目录。每次运行保留 `run.json`、带时间戳的 `events.jsonl`、脱敏后的 `stdout.log` 和 `stderr.log`,以及 `result.json`。签名失败还会写入 `fatal.json` 并通过 stderr 通知父进程;监督程序立即请求终止当前阶段的进程树并等待退出。失败阶段不能启动后续阶段或生成发布完成记录。日志写入失败也会停止运行。终止错误仍按失败处理,需要操作者检查;缺少最终记录表示尚未确认完成。 - -硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功且配置证书的主签名通过验证后才释放该文件;随后完成时间戳,不再访问硬件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。 - -Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 - -NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 - -在 `.env.windows` 中填写 `DSH_DESKTOP_WINDOWS_CER_FILE`(公开 EV 叶证书)、`DSH_DESKTOP_WINDOWS_SIGNTOOL`(SafeNet 兼容的 SignTool)、`DSH_DESKTOP_WINDOWS_KEY_CONTAINER`(匹配的私钥容器)和 `DSH_DESKTOP_WINDOWS_TOKEN_PIN`(Token Password)。私钥仍保留在 USB Token;不要把证书或本地凭据文件提交到 Git。 - -```sh -pnpm run package:desktop:win:x64 -``` - -打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 - -时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。 - -PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 - -使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: - -```sh -pnpm run package:desktop:dir -pnpm run package:desktop:mac:arm64:dir -``` - -需要检查或诊断为宿主目标准备的资源而不调用 electron-builder 时,可以让同一流水线在准备完成后停止: - -```sh -pnpm run prepare:desktop -``` - -这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 - -每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 - -macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 - -未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 + ## 更新 @@ -516,10 +346,14 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 下载内容是不可执行的测试字节,安装调用仅记录而不执行。测试替换浏览器打开与剪贴板写入,避免外部导航和剪贴板修改。它不启动完整产品工作区,不验证真实安装器或重启,不验证发布者签名,也不覆盖差分更新或 macOS。停滞的策略请求、清单请求和负载传输会执行真实截止时间及恢复。真实常规弹窗验证隔离预加载、卡片尺寸、未施加模糊的父页面、取消、任务警告选项与显式安装批准;账户行组件测试另行提供证据。[本地验证决策](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md)和[验证记录](tests/README.zh.md)保留这些限制;生产发布要求保持不变。 + + ## 底层开发覆盖项 未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 是带应用路径默认值的可选覆盖项。每次未打包启动都必须设置 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR`:开发启动器(`dev:desktop`、`start:desktop` 及工作区更新验证运行器)会把它设置为自己已准备目标的 primary-runtime 目录;缺少该变量的启动会以致命启动对话框失败。启动器必须设置它,因为壳无法从 `process.arch` 推导该目录:构建目标将 Windows 固定为 x64,而宿主可能是 arm64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 + + ## 已知限制 - 账号登录尚未接入;登录按钮禁用。Windows 材质效果仍需平台验证。 @@ -543,6 +377,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 账号提供者的 `embeddedPageDist` 配置为内嵌用量和充值页面 URL 添加 `dist` 查询参数。默认值为空;私有前端分支选择值应写在本地 profile patch 中。此配置不改变 API 地址或凭证传递方式。 + + ## 开发备注 上线前 CDN 与容量决策见[桌面更新提案](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md#cdn-and-capacity-qualification)。 diff --git a/apps/desktop/config/windows-packaging/production.env.example b/apps/desktop/config/windows-packaging/production.env.example new file mode 100644 index 0000000000..2e05825590 --- /dev/null +++ b/apps/desktop/config/windows-packaging/production.env.example @@ -0,0 +1,7 @@ +# Copy to production.env in the same machine configuration directory and fill in the values. +DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_AUTO_UPDATE_ENV=production +DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= +DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}' +# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= +DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 diff --git a/apps/desktop/config/windows-packaging/test.env.example b/apps/desktop/config/windows-packaging/test.env.example new file mode 100644 index 0000000000..718481f13a --- /dev/null +++ b/apps/desktop/config/windows-packaging/test.env.example @@ -0,0 +1,13 @@ +# Copy to test.env in the same machine configuration directory and fill in the values. +# Public test configuration. No PIN or upload credentials belong here. +DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_AUTO_UPDATE_ENV=test +DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= +# Replace login.example.com with the real login origin before packaging. +DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}' +# Required for signed test packages; retain the release ID for an upgrade test batch. +DOWNLOAD_TEST_ORIGIN= +DOWNLOAD_TEST_RELEASE_ID= +# Optional absolute private cache directory. Omit to retain the account default cache. +# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= +DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 diff --git a/apps/desktop/config/windows-packaging/windows-signing.env.example b/apps/desktop/config/windows-packaging/windows-signing.env.example new file mode 100644 index 0000000000..d086a84ae7 --- /dev/null +++ b/apps/desktop/config/windows-packaging/windows-signing.env.example @@ -0,0 +1,7 @@ +# Copy to windows-signing.env in the same machine configuration directory and fill in the values. +# Restrict this file to the runner account, SYSTEM and administrators. +# File paths resolve relative to this file; use the same account as local signing. +DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/windows.cer +DSH_DESKTOP_WINDOWS_SIGNTOOL= +DSH_DESKTOP_WINDOWS_KEY_CONTAINER= +DSH_DESKTOP_WINDOWS_TOKEN_PIN= diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 4e978ec00f..a4d0739791 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -9,10 +9,7 @@ export interface DesktopElectronBuilderConfig { readonly output: string } readonly files: readonly [ - string, - string, - string, - string, + ...string[], { readonly from: string, readonly to: 'dsh', readonly filter: readonly ['**/*'] }, { readonly from: string, readonly to: 'dsh/node_modules', readonly filter: readonly ['**/*'] }, ] diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0a84d09e6f..34e859cd20 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for a bundled dsh runtime and external plugins", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "private": true, "license": "MIT", "type": "module", @@ -60,23 +60,21 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^22.20.1", "@types/semver": "^7.8.0", - "app-builder-lib": "26.15.3", + "app-builder-lib": "26.17.0", "cos-nodejs-sdk-v5": "3.0.0", - "electron": "^44.0.0", - "electron-builder": "^26.15.3", + "electron": "^44.7.0", + "electron-builder": "^26.17.0", "extract-zip": "^2.0.1", "js-yaml": "^4.3.2", "koffi": "3.1.1", "pnpm": "11.28.5", - "sharp": "^0.35.5", + "sharp": "0.35.5", "typescript": "^6.0.3", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@types/ws": "^8.18.1", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "react": "^18.2.0", "react-dom": "^18.2.0", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "vite": "^6.4.3", "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml new file mode 100644 index 0000000000..dd7a091eb3 --- /dev/null +++ b/apps/desktop/packaging/README.i18n.yaml @@ -0,0 +1,55 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/packaging/README.md +/: + en: bcd0ee23f30dc6b8 + zh: cfc68b5580441da7 +/desktop-packaging: + en: b35e10c88ade04fe + zh: a2d09add03407b91 +/desktop-packaging/summary: + en: f9a25ac079e3cd67 + zh: 373edbce2c039d76 +/desktop-packaging/table-of-contents: + en: f50681ad48a7b2dc + zh: a9666ad04519d003 +/desktop-packaging/choose-a-build-path: + en: 8c66d96615e35904 + zh: e5a9e07266b635f2 +/desktop-packaging/release-versions: + en: 2531fc53354a1005 + zh: 1a26246deefe454a +/desktop-packaging/configuration-and-local-builds: + en: 929d18b05ebe00c2 + zh: 8fe4861196015a18 +/desktop-packaging/windows-actions-packaging: + en: 95a2c69e47137aa8 + zh: c1d78885cb961fc5 +/desktop-packaging/unsigned-windows-test-installer: + en: 6843a0fdc03ead58 + zh: 957f92fc8126e23e +/desktop-packaging/local-macos-dmg-without-release-signing: + en: 2feaa0412247d81f + zh: 461dde8e13fd4734 +/desktop-packaging/macos-release-signing-and-diagnostics: + en: f809d5c1820611ec + zh: 8a5a98e7625bf5b4 +/desktop-packaging/directory-builds-and-preparation: + en: 30d58517c9c2044f + zh: 16d26bdc923fc4ba +/desktop-packaging/local-official-bundles-from-a-loopback-registry: + en: b42f103358d0dfd7 + zh: a534164ad9eb00da +/desktop-packaging/runtime-file-selection-and-installation-checks: + en: 06d5c666a3e7f2e4 + zh: c1d4a55cc29e11e3 +/desktop-packaging/desktop-runtime-preparation: + en: a7aa3066e649ffe8 + zh: 4334637910058c09 +/desktop-packaging/installation-and-release-verification: + en: 0006b6b1ae4998f4 + zh: 2bab6918b0c37b25 +/desktop-packaging/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md new file mode 100644 index 0000000000..dc64200307 --- /dev/null +++ b/apps/desktop/packaging/README.md @@ -0,0 +1,247 @@ +--- +description: "Build Desktop installers locally or through Windows Actions, choose deployment and signing mode, and retrieve artifacts and diagnostics. This guide keeps build operations separate from publishing and installed-application qualification. Platform signing details and shared-runner risks must be checked before using hardware credentials." +--- + +# Desktop packaging + +English | [中文](README.zh.md) + +## Summary + +Build Desktop installers locally or through Windows Actions, choose deployment and signing mode, and retrieve artifacts and diagnostics. This guide keeps build operations separate from publishing and installed-application qualification. Platform signing details and shared-runner risks must be checked before using hardware credentials. + +## Table of Contents + +- [Choose a build path](#choose-a-build) +- [Release versions](#release-versions) +- [Configuration and local builds](#configuration-and-local-builds) +- [Windows Actions packaging](#windows-actions) +- [Unsigned Windows test installer](#unsigned-windows-test-installer) +- [Local macOS DMG without release signing](#local-macos-dmg-without-release-signing) +- [macOS release signing and diagnostics](#macos-release-signing) +- [Directory builds and preparation](#directory-and-preparation) +- [Local Official bundles from a loopback registry](#local-official-bundles-from-a-loopback-registry) +- [Runtime file selection and installation checks](#runtime-file-selection) +- [Desktop runtime preparation](#desktop-runtime-preparation) +- [Installation and release verification](#verification) + + + +## Choose a build path + +Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or Windows Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation. + + + +## Release versions + +Before each Desktop packaging run, first confirm the complete version string with the current user. Check the selected deployment, dsh base version, retained release records, and published objects, then propose the exact version for approval. Do not start packaging until the user confirms that version; the deployment setting alone does not authorize a version choice. + +Record the current dsh version as the base. A production Desktop release uses that exact version, including any `alpha`, `beta`, or `rc` identifiers. A test release preserves the complete prerelease base and appends `.YYYYMMDD.index`; a stable base uses `-test.YYYYMMDD.index` instead. + +| dsh base | Production Desktop | Test Desktop example | +|---|---|---| +| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | +| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | +| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | +| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | + +Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Test distribution does not publish the corresponding unsuffixed base. + +Manual Windows CI uses a separate unpublished build identifier: `auto` appends the UTC date, workflow run number and attempt (`.YYYYMMDD.run.attempt`, with `-test` for a stable base). For example, `0.2.1-alpha.1.20261008.42.2`. These numbers are not reserved release sequence numbers. + +Pass the confirmed version to the packaging command as `--build-version`, which reaches the artifact names, the update feed, and the upload validation as one value. The manifests keep the product version, so a test build no longer rewrites the release family and leaves nothing to revert: + +```sh +pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 +``` + +`--build-version auto` proposes the next index for today, reading the published objects in the destination bucket and falling back to this target's local output directory when no bucket is configured or the listing cannot finish within its deadline. Confirm the proposal it prints before an upload; a run script forwards `--` on its own, and the packaging entry accepts it either way. + +A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. + +Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier. + + + +## Configuration and local builds + +Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned and preparation-only presets never read the signing file. Cache maintenance also reads only public preset settings. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt. + +Outside machine-preset mode, packaging, upload, and manual macOS signature verification (except local unsigned macOS builds) read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](../.env.windows.example) or [macOS template](../.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the selected configuration files, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. Windows CI reads the same machine presets. + +Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Without a Windows machine preset, `--build-version auto` contacts the destination bucket, including under `--check`. Run the same checks separately: + +```sh +pnpm --dir apps/desktop run check:package +``` + +`prepare:desktop` is not a prerequisite: + +```sh +pnpm run package:desktop +``` + +Release automation uses fixed target commands so runtime preparation, dsh preparation, and electron-builder receive the same platform and architecture: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run package:desktop:mac:x64 +pnpm run package:desktop:win:x64 +``` + +The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target. + +Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Electron archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. + + + +## Windows Actions packaging + +The [manual Windows workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `unsigned` (default) or `signed`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged. + +Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. + +Open the completed run in Actions and download its Artifacts. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. + + + +## Unsigned Windows test installer + +On Windows x64, use the complete unsigned packaging command for local installation testing: + +```sh +pnpm run package:desktop:win:x64:unsigned +``` + +The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell, tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements. + +Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths. + + + +## Local macOS DMG without release signing + +After `pnpm install`, run one command from the repository root. No `.env.macos`, Apple account, certificate, or update service configuration is required: + +```sh +pnpm run package:desktop:mac:arm64:unsigned +pnpm run package:desktop:mac:x64:unsigned +``` + +Add `--check` to validate local configuration and tools without building, or `--dir` to stop at the unpacked application. The DMG is `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`. After all checks pass, the command prints `DMG:` followed by its absolute path. Open it and copy the application to a local test directory. + +These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. + + + +## macOS release signing and diagnostics + +The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. + +macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. + +macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache`. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. + +Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. + +App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log. + +Mac packaging reads three tuning fields from `.env.macos`: + +| Setting | Default | Scope | +|---|---|---| +| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. | +| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. | +| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. | + +The proxy fields are independent and reject URL credentials, paths, queries and fragments. Empty values preserve inherited networking. An explicit download proxy replaces child-process proxy variables and bypasses local hosts only; it does not change system settings. Windows and standalone `release:pack` retain their existing concurrency defaults. Company proxy addresses belong in the ignored local file; see internal documentation for addresses. + +Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. The lock loads `@deepseek-ai/node-addon-system/flock` at first use rather than at script start, so `check:package` and the packaging entry load on a checkout that has not built `native/system`; when the host addon binary or the entry's JavaScript is missing at lock time, the loader runs `pnpm run build:native-system` and `pnpm --dir native/system run build:ts` before locking, so recovery works on such a checkout as well. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy. + + + +## Directory builds and preparation + +Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: + +```sh +pnpm run package:desktop:dir +pnpm run package:desktop:mac:arm64:dir +``` + +To inspect or troubleshoot the prepared host-target resources without invoking electron-builder, stop the same pipeline after preparation: + +```sh +pnpm run prepare:desktop +``` + +This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content. + + + +## Local Official bundles from a loopback registry + +The test DMG excludes the provider runtimes the Official on-demand catalog advertises, and a release publishes matching bundle versions to npm. To install locally built bundles before that release, start a loopback registry that packs and serves the catalog's complete local dependency closure: + +```sh +pnpm run dev:bundle-registry +``` + +The command validates the catalog and the workspace inventory, runs `pnpm run build:official`, packs the closure into a new `dist/test-bundles//` directory, writes `bundle-registry.json` last as the completion marker, and only then prints the artifact path, the registry URL, and the exact `name@version` specs. It serves `127.0.0.1` on an allocated port under a fresh namespace until Ctrl+C or SIGTERM, and stopping it leaves both the artifacts and the installed packages on disk. It needs no `.env.macos`, Apple credentials, or npm credentials, and it does not change `pnpm run package:desktop:unsigned`. + +In the packaged application, open Plugins → Add plugin, paste one printed spec, choose **Custom address**, paste the printed registry URL, and install. A custom registry is asked alone, so this process must answer every dependency: repository-owned packages come from the run's own archives, and third-party packages keep npm's real metadata and bytes through a fixed redirect to `registry.npmjs.org`, which still requires network access. The installed entry stays Official because its package name is in the catalog. The registry choice is remembered in this browser; choose an offered registry again to switch back. + +A run's artifacts are immutable, so a rebuild serves a new namespace and a new URL, and the Official **Update** control does not appear while the installed and running versions match. The profile records the previous run's tarball URL in `pnpm-lock.yaml`, and pnpm verifies recorded entries, so installing or reinstalling that bundle against a new URL is refused with `ERR_PNPM_TARBALL_URL_MISMATCH` and can leave the earlier files in place. To test changed code at the same version, clear the stale resolution first — `pnpm clean --lockfile` in `$DSH_HOME/profiles/`, or install into a fresh profile — then install again from the new URL. `bundle-registry.json` records each archive's version, filename, size, and SHA-512 integrity together with the packing checkout's commit and dirty flag. + +The served `time` is the real packing time, so pnpm 11's default 24-hour `minimumReleaseAge` records the exemption it granted in the profile's `pnpm-workspace.yaml` (`minimumReleaseAgeExclude`); an explicitly stricter policy there fails the installation with pnpm's own message instead of being overridden. A `@scope:registry` route in `~/.npmrc` or the profile's `.npmrc` overrides `--registry` for that scope, so it must not route the served scope. + + + +## Runtime file selection and installation checks + +Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](../scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory and Yarn development metadata, selected native compiler outputs, and node-pty prebuilds for other platforms. Non-Windows targets also omit `node-pty/third_party/conpty`; Windows targets retain that directory. It preserves runtime JavaScript, target native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. + +The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions. + +The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). + +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. + +Windows release qualification also runs [directory and replacement checks](../scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. + +```powershell +pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll +``` + +The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. + +When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\-updater\installer-logs\extract-failure-.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. + +Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. + +NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. + + + +## Desktop runtime preparation + +Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](../scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures. + +Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration. + +Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. + +Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload. + +An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine. + + + +## Installation and release verification + +Keep the generated installer, its version and packaging records together. Review signatures and runtime checks before installation, then follow the [installed-update qualification procedure](../tests/installed-update/README.md) for installed launch and upgrade evidence. The [Desktop test record](../tests/README.md) owns verification limits; a successful workflow alone does not establish production preset, new-workstation or installed-upgrade acceptance. + +## Dev Note + +None. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md new file mode 100644 index 0000000000..ff75c4af9a --- /dev/null +++ b/apps/desktop/packaging/README.zh.md @@ -0,0 +1,247 @@ +--- +description: "在本地或 Windows Actions 中生成 Desktop 安装包,选择部署与签名模式,并获取产物和诊断。本指南区分构建操作、发布与已安装应用验收。使用硬件凭据前,必须检查平台签名要求与共享 runner 风险。" +--- + +# Desktop 打包 + +[English](README.md) | 中文 + +## 概述 + +在本地或 Windows Actions 中生成 Desktop 安装包,选择部署与签名模式,并获取产物和诊断。本指南区分构建操作、发布与已安装应用验收。使用硬件凭据前,必须检查平台签名要求与共享 runner 风险。 + +## 目录 + +- [选择打包方式](#choose-a-build) +- [发布版本](#release-versions) +- [配置与本地构建](#configuration-and-local-builds) +- [Windows Actions 打包](#windows-actions) +- [未签名 Windows 测试安装包](#unsigned-windows-test-installer) +- [无发布签名的本地 macOS DMG](#local-macos-dmg-without-release-signing) +- [macOS 发布签名与诊断](#macos-release-signing) +- [目录构建与准备](#directory-and-preparation) +- [用回环 registry 安装本地 Official 包](#local-official-bundles-from-a-loopback-registry) +- [运行时文件筛选与安装检查](#runtime-file-selection) +- [Desktop 运行时准备](#desktop-runtime-preparation) +- [安装与发布验收](#verification) + + + +## 选择打包方式 + +安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用 Windows Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。 + + + +## 发布版本 + +每次 Desktop 打包前,第一步都要与当前用户确认完整版本号。检查所选部署环境、dsh 基础版本、保留的发布记录和已发布对象,再提出准确版本供用户确认。用户确认前不得启动打包;仅选择部署环境不代表用户已认可版本号。 + +记录当前 dsh 版本作为基础版本。production Desktop 使用完全相同的版本,包括其中的 `alpha`、`beta` 或 `rc` 标识。test 发布保留完整的预发布基础版本并追加 `.YYYYMMDD.index`;稳定基础版本则追加 `-test.YYYYMMDD.index`。 + +| dsh 基础版本 | production Desktop | test Desktop 示例 | +|---|---|---| +| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | +| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | +| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | +| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | + +日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。test 分发不发布对应的无后缀基础版本。 + +Windows 手动 CI 使用独立的未发布构建标识:`auto` 追加 UTC 日期、工作流运行编号和尝试次数(`.YYYYMMDD.run.attempt`,稳定基础版本使用 `-test`)。例如 `0.2.1-alpha.1.20261008.42.2`。这些编号不是预留的发布序号。 + +把确认后的版本通过 `--build-version` 传给打包命令,该值同时决定产物文件名、更新 feed 与上传校验。清单保留产品版本,因此 test 打包不再改写发布家族,也不留下需要还原的改动: + +```sh +pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 +``` + +`--build-version auto` 会给出当天的下一个序号:读取目标 bucket 中已发布的对象,未配置 bucket 或列举未能在期限内完成时回退到本目标的本地输出目录。上传前请确认它打印的版本号;run script 会自行透传 `--`,打包入口两种写法都接受。 + +production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 + +版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。 + + + +## 配置与本地构建 + +本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名和仅准备 preset 不读取签名文件。缓存维护也只读取公开 preset 配置。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。 + +不选择机器 preset 时,打包、上传以及手动 macOS 签名检查(本地 macOS 未签名构建除外)使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](../.env.windows.example) 或 [macOS 模板](../.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从选定的配置文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。Windows CI 读取相同的机器 preset。 + +每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。未选择 Windows 机器 preset 时,`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查: + +```sh +pnpm --dir apps/desktop run check:package +``` + +无需提前执行 `prepare:desktop`: + +```sh +pnpm run package:desktop +``` + +发布自动化使用固定目标命令,确保运行时准备、dsh 准备与 electron-builder 接收相同的平台和架构: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run package:desktop:mac:x64 +pnpm run package:desktop:win:x64 +``` + +macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Linux 不是受支持的 Desktop 发布目标。 + +每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Electron 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 + + + +## Windows Actions 打包 + +[Windows 手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `unsigned`(默认)或 `signed`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。 + +在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 + +在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 + + + +## 未签名 Windows 测试安装包 + +在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试: + +```sh +pnpm run package:desktop:win:x64:unsigned +``` + +该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。 + +先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。 + + + +## 无发布签名的本地 macOS DMG + +执行 `pnpm install` 后,在仓库根目录运行一条命令即可,无需 `.env.macos`、Apple 账号、证书或更新服务配置: + +```sh +pnpm run package:desktop:mac:arm64:unsigned +pnpm run package:desktop:mac:x64:unsigned +``` + +添加 `--check` 仅验证本地配置和工具而不构建,或添加 `--dir` 只生成解包应用。DMG 路径为 `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`。所有检查通过后,命令打印 `DMG:` 及其绝对路径。打开后将应用复制到本地测试目录。 + +这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。 + + + +## macOS 发布签名与诊断 + +macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 + +macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 + +macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 + +Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 + +App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 + +Mac 打包从 `.env.macos` 读取三个调优字段: + +| 设置 | 默认值 | 作用范围 | +|---|---|---| +| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 | +| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 | +| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 | + +两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。 + +Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。该锁在首次使用时才加载 `@deepseek-ai/node-addon-system/flock`,而不是在脚本启动时加载,因此 `check:package` 和打包入口在未构建 `native/system` 的 checkout 上也能加载;加锁时若宿主 addon 二进制或入口的 JavaScript 缺失,加载器会先运行 `pnpm run build:native-system` 和 `pnpm --dir native/system run build:ts` 再加锁,因此恢复命令在这样的 checkout 上同样可用。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。 + + + +## 目录构建与准备 + +使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: + +```sh +pnpm run package:desktop:dir +pnpm run package:desktop:mac:arm64:dir +``` + +需要检查或诊断为宿主目标准备的资源而不调用 electron-builder 时,可以让同一流水线在准备完成后停止: + +```sh +pnpm run prepare:desktop +``` + +这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 + + + +## 用回环 registry 安装本地 Official 包 + +测试 DMG 不包含 Official 按需目录所宣传的 provider 运行时,而正式发布才会把对应版本的 bundle 发到 npm。要在发布前安装本地构建的 bundle,请启动一个回环 registry,它会打包并提供该目录的完整本地依赖闭包: + +```sh +pnpm run dev:bundle-registry +``` + +该命令先校验目录与 workspace 清单,运行 `pnpm run build:official`,把闭包打包进新的 `dist/test-bundles//` 目录,最后写入 `bundle-registry.json` 作为完成标记,然后才打印产物路径、registry URL 和精确的 `name@version` 规格。它在分配到的端口上只监听 `127.0.0.1`,使用一次性的命名空间提供服务,直到 Ctrl+C 或 SIGTERM;停止后产物和已安装的包都保留在磁盘上。它不需要 `.env.macos`、Apple 凭据或 npm 凭据,也不改变 `pnpm run package:desktop:unsigned`。 + +在打包应用中打开“插件 → 添加插件”,粘贴一条打印出的规格,选择**自定义地址**,粘贴打印出的 registry URL 后安装。自定义 registry 会被单独询问,因此必须由本进程回答每一个依赖:仓库自有的包取自本次运行的产物,第三方包则通过固定重定向到 `registry.npmjs.org` 保留 npm 的真实元数据与字节,因此仍需要网络访问。安装后的条目仍归类为 Official,因为它的包名在目录中。registry 选择会在此浏览器中记住;再次选择某个提供的 registry 即可切回。 + +一次运行的产物是不可变的,所以重新构建会提供新的命名空间和新 URL;当已安装版本与运行版本一致时,Official 的**更新**控件不会出现。profile 会在 `pnpm-lock.yaml` 中记录上一次运行的 tarball URL,而 pnpm 会校验已记录的条目,因此用新 URL 再次安装或重装该 bundle 会被 `ERR_PNPM_TARBALL_URL_MISMATCH` 拒绝,并且可能继续保留旧文件。要在同一版本上验证改动后的代码,请先清除过期的解析结果——在 `$DSH_HOME/profiles/` 中运行 `pnpm clean --lockfile`,或改用全新的 profile——然后再从新 URL 安装。`bundle-registry.json` 记录每个归档的版本、文件名、大小和 SHA-512 完整性,以及打包所用 checkout 的 commit 和 dirty 标记。 + +所提供的 `time` 是真实的打包时间,因此 pnpm 11 默认的 24 小时 `minimumReleaseAge` 会在 profile 的 `pnpm-workspace.yaml` 中记录它授予的豁免(`minimumReleaseAgeExclude`);如果那里显式配置了更严格的策略,安装会以 pnpm 自己的报错失败,而不会被覆盖。`~/.npmrc` 或 profile 的 `.npmrc` 中的 `@scope:registry` 路由会覆盖该 scope 的 `--registry`,因此不能让它们指向正在提供服务的 scope。 + + + +## 运行时文件筛选与安装检查 + +Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](../scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录及 Yarn 开发元数据、选定的原生编译器输出和其他平台的 node-pty 预构建文件。非 Windows 目标还会排除 `node-pty/third_party/conpty`;Windows 目标保留该目录。它保留运行时 JavaScript、目标平台的原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 + +[Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。 + +[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 + +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 + +Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](../scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 + +```powershell +pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll +``` + +Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 + +解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 + +Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 + +NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 + + + +## Desktop 运行时准备 + +Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](../scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。 + +Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。 + +每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 + +macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 + +未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 + + + +## 安装与发布验收 + +一起保留生成的安装包、版本和打包记录。安装前检查签名与运行时验证结果,再按[已安装更新验收流程](../tests/installed-update/README.zh.md)记录安装后启动及升级证据。[Desktop 测试记录](../tests/README.zh.md)维护验证限制;工作流成功本身不代表 production preset、新工作站或已安装升级验收通过。 + +## 开发备注 + +无。 diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml new file mode 100644 index 0000000000..570a6157aa --- /dev/null +++ b/apps/desktop/publishing/README.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/publishing/README.md +/: + en: 32f414f3e27d5afd + zh: e16c49177f708c47 +/desktop-publication: + en: 03551b589405750e + zh: ccbf7e38dcf5d452 +/desktop-publication/summary: + en: 722633597f74ace6 + zh: 5303bcf5567a8511 +/desktop-publication/table-of-contents: + en: 64af8f421bc63580 + zh: 69844c0a0d4771f6 +/desktop-publication/publication-prerequisites: + en: b407de63118c9bf9 + zh: de19f1ba9feacbbf +/desktop-publication/deployment-and-test-release-identity: + en: 405588e021bcfb60 + zh: d9f9700b0b6db6f8 +/desktop-publication/publish-installers-and-update-feeds: + en: 3c3e3a09573c4386 + zh: c851ae6a94f2284a +/desktop-publication/fixed-installer-downloads: + en: 38054a5f89590cfa + zh: 9e115f9bdeeab213 +/desktop-publication/upload-credentials: + en: 1c5689525753b08d + zh: 93089de765806733 +/desktop-publication/upload-audit-and-recovery: + en: ec85f43ece09a5e3 + zh: 2de5bf801c9c9e39 +/desktop-publication/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md new file mode 100644 index 0000000000..0a698741fa --- /dev/null +++ b/apps/desktop/publishing/README.md @@ -0,0 +1,86 @@ +--- +description: "Publish completed Desktop installers and update feeds to the selected download service. This reference covers deployment identity, upload credentials, fixed downloads and audit records. Build configuration and signing operations have separate owners." +--- + +# Desktop publication + +English | [中文](README.zh.md) + +## Summary + +Publish completed Desktop installers and update feeds to the selected download service. This reference covers deployment identity, upload credentials, fixed downloads and audit records. Build configuration and signing operations have separate owners. + +## Table of Contents + +- [Publication prerequisites](#prerequisites) +- [Deployment and test release identity](#upload-updates) +- [Publish installers and update feeds](#publish-feeds) +- [Fixed installer downloads](#fixed-downloads) +- [Upload credentials](#credentials) +- [Upload audit and recovery](#audit-and-recovery) + + + +## Publication prerequisites + +Publication requires completed release artifacts and explicit operator authorization. Building an installer or downloading an Actions artifact does not publish it. Read the [release version rules](../packaging/README.md#release-versions) and retain the packaging evidence. Machine presets are for packaging, not upload/COS commands: unset `DSH_PACKAGING_PRESET` and provide the existing target dotenv upload configuration. The configuration and actual COS/CDN acceptance must be verified by the release operator. + + + +## Deployment and test release identity + +`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. + +The update destination and upload credentials follow the selected deployment: + +| Environment | Public origin | COS bucket | COS credentials | +|---|---|---|---| +| `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | +| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | + +Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. To test an upgrade between versions, reuse the installed client’s ID for the later version. Production does not use this field. + +```sh +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" +``` + +Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Format validation cannot establish randomness; use the generator output. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. + + + +## Publish installers and update feeds + +Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run upload:mac:arm64 +``` + +The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. + +Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. + +Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. + + + +## Fixed installer downloads + +Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. + + + +## Upload credentials + +Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. + + + +## Upload audit and recovery + +Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. + +## Dev Note + +None. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md new file mode 100644 index 0000000000..7f7f230065 --- /dev/null +++ b/apps/desktop/publishing/README.zh.md @@ -0,0 +1,86 @@ +--- +description: "将完整 Desktop 安装包与更新清单发布到选定下载服务。本参考文档说明部署标识、上传凭据、固定下载和审计记录。打包配置与签名操作由独立文档维护。" +--- + +# Desktop 发布 + +[English](README.md) | 中文 + +## 概述 + +将完整 Desktop 安装包与更新清单发布到选定下载服务。本参考文档说明部署标识、上传凭据、固定下载和审计记录。打包配置与签名操作由独立文档维护。 + +## 目录 + +- [发布前提](#prerequisites) +- [部署环境与测试发布标识](#upload-updates) +- [发布安装包与更新清单](#publish-feeds) +- [固定安装包下载](#fixed-downloads) +- [上传凭据](#credentials) +- [上传审计与恢复](#audit-and-recovery) + + + +## 发布前提 + +发布需要完整的 release 产物和操作者明确授权。生成安装包或下载 Actions 产物不会发布它。先阅读[发布版本规则](../packaging/README.zh.md#release-versions)并保留打包证据。机器 preset 用于打包,不支持 upload/COS 命令:取消设置 `DSH_PACKAGING_PRESET`,并提供既有目标 dotenv 上传配置。配置以及真实 COS/CDN 验收由发布操作者验证。 + + + +## 部署环境与测试发布标识 + +`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 + +更新目标与上传凭据都与所选环境对应: + +| 环境 | 公开 origin | COS bucket | COS 凭据 | +|---|---|---|---| +| `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | +| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | + +每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。需要验证跨版本升级时,后续版本沿用已安装客户端的 ID。生产环境不使用该字段。 + +```sh +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" +``` + +通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。格式校验无法判断随机性,请使用生成器的输出。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 + + + +## 发布安装包与更新清单 + +在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run upload:mac:arm64 +``` + +内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 + +前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 + +Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 + + + +## 固定安装包下载 + +固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 + + + +## 上传凭据 + +Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 + + + +## 上传审计与恢复 + +test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 + +## 开发备注 + +无。 diff --git a/apps/desktop/scripts/desktop-build-version-discovery.ts b/apps/desktop/scripts/desktop-build-version-discovery.ts index fd5a2c9e10..120e41afd0 100644 --- a/apps/desktop/scripts/desktop-build-version-discovery.ts +++ b/apps/desktop/scripts/desktop-build-version-discovery.ts @@ -2,7 +2,7 @@ * Suggest the next build version for a product version, so the sequence number * is derived rather than remembered. * - * `apps/desktop/README.md` gives the form as `..` + * `apps/desktop/packaging/README.md#release-versions` gives the form as `..` * for a prerelease base and `-test..` for a * stable one. What makes an index correct is which ones are taken, and that is * recorded where the builds went: the update bucket for an uploaded build, the diff --git a/apps/desktop/scripts/desktop-build-version.mjs b/apps/desktop/scripts/desktop-build-version.mjs index e2152ce0d3..9bf08681a7 100644 --- a/apps/desktop/scripts/desktop-build-version.mjs +++ b/apps/desktop/scripts/desktop-build-version.mjs @@ -2,12 +2,15 @@ * Resolve the version one build publishes, which is not always the version the * repository declares. * + * Manual Windows CI uses UTC date, workflow run number and attempt as + * `.YYYYMMDD.run.attempt`; these identify unpublished builds, not release sequences. + * * A production release publishes the version in the manifests, aligned with the * `dsh` npm package. A test build publishes a version that appends a date and a * sequence number, so one test feed can carry several builds of a single * product version: `0.1.6-alpha.1.20260916.1` from a prerelease base and * `0.1.6-test.20260916.1` from a stable one, as the release versions table in - * `apps/desktop/README.md` gives them. Passing that version here keeps it out + * `apps/desktop/packaging/README.md#release-versions` gives them. Passing that version here keeps it out * of the manifests, so the tracked version stays the product's while the build * version reaches electron-builder, the update feed, and the upload validation * as one input. @@ -71,7 +74,8 @@ export function validateDesktopBuildVersion(buildVersion, productVersion) { && required.every((field, index) => build.prerelease[index] === field) if (!extendsProduct) { throw new Error(`desktop build version: ${buildVersion} must extend ${productVersion} as ${ - desktopBuildVersionPrefix(productVersion)}.`) + desktopBuildVersionPrefix(productVersion)}. or ${ + desktopBuildVersionPrefix(productVersion)}..`) } return build.version } diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index a49ca46eb9..90659b6e99 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -8,16 +8,18 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv /** - * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values. + * Read a Windows machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * @param platform Target platform. * @param environment Parent environment, retained only for unrelated build tools. * @param appRoot Desktop application directory; relative credential paths resolve here. + * @param options Unsigned and preparation-only presets never read signing credentials. * @returns Isolated environment with file-owned release settings. */ export function loadDesktopPackageEnvironment( platform: 'win32' | 'darwin', environment?: NodeJS.ProcessEnv, appRoot?: string, + options?: { unsigned?: boolean, prepareOnly?: boolean }, ): NodeJS.ProcessEnv /** diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 51b878100a..9c7d52345b 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -1,7 +1,7 @@ /** Select local test defaults or platform release settings without changing the caller's environment. */ import { accessSync, constants, readFileSync, statSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseEnv } from 'node:util' import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs' @@ -33,14 +33,46 @@ export function localMacOSPackageEnvironment(environment = process.env) { } /** - * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values. + * Read a Windows machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * @param {'win32' | 'darwin'} platform Target platform. * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools. * @param {string} appRoot Desktop application directory; relative credential paths resolve here. + * @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Unsigned and preparation-only presets never read signing credentials. * @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings. */ -export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT) { - const path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') +export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) { + const preset = environment.DSH_PACKAGING_PRESET + let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') + if (preset !== undefined) { + if (platform !== 'win32') throw new Error('desktop package: presets support Windows only') + if (preset !== 'test' && preset !== 'production') throw new Error('desktop package: preset must be test or production') + const root = environment.DSH_PACKAGING_CONFIG_DIR + if (!root || !isAbsolute(root)) throw new Error('desktop package: DSH_PACKAGING_CONFIG_DIR must be an absolute directory') + path = join(root, 'presets', `${preset}.env`) + } + const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING + const settings = readSettings(path, name => preset === undefined + ? SHARED_SETTING.test(name) || platformSetting.test(name) + : /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN)|WINDOWS_SIGNATURE_CACHE_(?:DIR|CONCURRENCY))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name)) + if (preset !== undefined) { + if (settings.DSH_DESKTOP_AUTO_UPDATE_ENV !== preset) throw new Error('desktop package: preset deployment must match its name') + if (!options.unsigned && !options.prepareOnly) Object.assign(settings, readSettings(join(dirname(dirname(path)), 'secrets', 'windows-signing.env'), + name => /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u.test(name))) + } + return { + ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), + ...settings, + ...(preset === undefined ? {} : { DSH_PACKAGING_PRESET: preset }), + } +} + +/** + * Read an explicitly selected dotenv without exposing credential-bearing parser errors. + * @param {string} path Selected file; relative file settings resolve beside it. + * @param {(name: string) => boolean} accepts Whether this file may declare a setting. + * @returns {NodeJS.ProcessEnv} Validated file settings. + */ +function readSettings(path, accepts) { let contents try { contents = readFileSync(path, 'utf8') @@ -56,9 +88,8 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en // Parser diagnostics can contain credential-bearing input. throw new Error(`desktop package: invalid dotenv syntax in ${path}`) } - const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING for (const name of Object.keys(settings)) { - if (!SHARED_SETTING.test(name) && !platformSetting.test(name)) { + if (!accepts(name)) { throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`) } if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`) @@ -66,10 +97,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en for (const name of FILE_SETTINGS) { if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim()) } - return { - ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), - ...settings, - } + return settings } function requireReadableFile(environment, name) { diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 1ae2f3e952..37515c20b0 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -93,7 +93,7 @@ async function launchElectron(): Promise { async function main(): Promise { const { values } = parseArgs({ options: { 'skip-build': { type: 'boolean', default: false } } }) if (!values['skip-build']) { - await runPackageScript('build', REPOSITORY_ROOT) + await runPackageScript('build:artifacts', REPOSITORY_ROOT) await runPackageScript('build', APP_ROOT) } for (const path of [ diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index f08b783865..747d20d841 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -135,6 +135,8 @@ export function createElectronBuilderConfig( 'lib/preload-welcome.cjs', 'renderer/**/*', 'package.json', + '!node_modules/**/*.{js,cjs,mjs,ts,cts,mts,css}.map', + '!node_modules/**/*.tsbuildinfo', { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] }, // electron-builder excludes a source directory's root node_modules. { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }, diff --git a/apps/desktop/scripts/installed-update-builder.ts b/apps/desktop/scripts/installed-update-builder.ts index 2f0cbefe6c..42c665c430 100644 --- a/apps/desktop/scripts/installed-update-builder.ts +++ b/apps/desktop/scripts/installed-update-builder.ts @@ -31,6 +31,7 @@ export async function createInstalledUpdateBuilderConfig(manifest: string, versi files: [ { from: application, to: '.', filter: ['lib/*.js', 'lib/*.cjs', 'renderer/**/*', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'] }, 'package.json', + ...config.files.filter((file): file is string => typeof file === 'string' && file.startsWith('!')), { from: dsh, to: 'dsh', filter: ['**/*'] }, { from: join(dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }, ], diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index 3d5fabdbed..1d4c27c0d5 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -1,8 +1,9 @@ /** Sign final native runtime files before the enclosing Desktop application is signed. */ import { createHash } from 'node:crypto' -import { closeSync, openSync, readSync } from 'node:fs' -import { join } from 'node:path' +import { closeSync, openSync, readdirSync, readSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { primaryRuntimePythonAliases, readPrimaryRuntime } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { inventoryDesktopRuntime } from '../src/runtime-tree.ts' import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' import { cachedMacOSSignature, pruneMacOSSignatureCache } from './macos-signature-cache.ts' @@ -19,19 +20,34 @@ function magic(path: string): string { } finally { closeSync(descriptor) } } +async function primaryRuntimeFiles(root: string): Promise { + const aliases = await primaryRuntimePythonAliases(root, await readPrimaryRuntime(root)) + const files: string[] = [] + for (const entry of readdirSync(root, { recursive: true, withFileTypes: true })) { + const path = relative(root, join(entry.parentPath, entry.name)).split(sep).join('/') + if (entry.isDirectory() || (entry.isSymbolicLink() && aliases.has(path))) continue + if (!entry.isFile()) throw new Error(`desktop runtime: unsupported filesystem entry ${path}`) + files.push(path) + } + return files.sort() +} + /** * Sign and verify every materialized Mach-O file, awaiting all signers on failure. - * @param root - Self-contained production runtime without symlinks. + * @param root - Self-contained production runtime. * @param appId - Release application identifier. * @param expected - Required signing identity. * @param arch - Target runtime architecture, independent of the signing host. * @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe. + * @param kind - Primary payloads admit validated Python aliases; Desktop application runtimes reject every link. * @returns Number of signed native files. */ export async function signMacOSRuntime( root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string, + kind: 'desktop' | 'primary' = 'desktop', ): Promise { - const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) + const inventory = kind === 'primary' ? await primaryRuntimeFiles(root) : inventoryDesktopRuntime(root).map(file => file.path) + const files = inventory.filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '') let hits = 0 let misses = 0 diff --git a/apps/desktop/scripts/manage-windows-signature-cache.mjs b/apps/desktop/scripts/manage-windows-signature-cache.mjs index de44540c70..f36aaf5925 100644 --- a/apps/desktop/scripts/manage-windows-signature-cache.mjs +++ b/apps/desktop/scripts/manage-windows-signature-cache.mjs @@ -11,7 +11,7 @@ const { values } = parseArgs({ options: { if ([values.from !== undefined, values.usage === true, values.clear === true].filter(Boolean).length !== 1) { throw new Error('Specify exactly one of --from , --usage or --clear') } -const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32') +const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32', process.env, undefined, { unsigned: true }) : { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.directory } const root = resolveWindowsSignatureCacheDirectory(environment) const record = event => process.stdout.write(`${JSON.stringify(event)}\n`) diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index a133ccdcf8..505b7c7598 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -322,7 +322,9 @@ async function resolveRequestedBuildVersion( if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion) const paths = desktopTargetBuildPaths(invocation.target.name) return suggestDesktopBuildVersion({ - productVersion, target: invocation.target.name, environment, + productVersion, target: invocation.target.name, + // Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts. + environment: environment.DSH_PACKAGING_PRESET === undefined ? environment : {}, // Unsigned builds land beside the signed output, so numbering has to read the directory this run writes. artifactsRoot: invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts, }) @@ -333,9 +335,11 @@ async function main(): Promise { const { target } = invocation const environment = target.platform === 'darwin' && invocation.unsigned ? localMacOSPackageEnvironment() - : loadDesktopPackageEnvironment(target.platform) + : loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, { + unsigned: invocation.unsigned, prepareOnly: invocation.prepareOnly, + }) const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') - // Release settings come from the target dotenv file alone, so the version this run publishes is an + // Release settings come from the selected configuration files, so the version this run publishes is an // argument; the environment variable below only carries it to the child processes that build. const buildVersion = await resolveRequestedBuildVersion(invocation, productVersion, environment) environment[DESKTOP_BUILD_VERSION_ENV] = buildVersion diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 4e03f763cb..808b8ae8b5 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -20,6 +20,7 @@ import { import { smokePrimaryRuntime } from './prepare-primary-runtime.ts' import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts' +import { prepareRuntimePatches, verifyRuntimePatches } from './prepare-runtime-patches.ts' import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { resolveDesktopAppId, @@ -42,7 +43,7 @@ const RUNTIME_ROOT = BUILD_PATHS.runtime const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet const NODE = join(BUILD_PATHS.electron, process.platform === 'win32' ? 'electron.exe' : 'Electron.app/Contents/MacOS/Electron') -const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs') +const PNPM = join(RUNTIME_ROOT, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs') function manifestVersion(path: string, subject: string): string { const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } @@ -117,12 +118,14 @@ async function main(): Promise { mkdirSync(STORE_ROOT, { recursive: true }) }) const release = desktopRelease() - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:stage-packages', async () => { + const patches = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:stage-packages', async () => { copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE)) cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true }) createRuntimeProjectMetadata(BUILD_ROOT, release) + return prepareRuntimePatches(BUILD_ROOT, resolve(APP_ROOT, '..', '..')) }) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:lockfile', () => runPnpm(['install', '--lockfile-only'])) + verifyRuntimePatches(BUILD_ROOT, patches) verifyDesktopCoreLockfile( readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'), readDesktopCorePackageSet(BUILD_ROOT, release.version), @@ -155,7 +158,7 @@ async function main(): Promise { } if (process.platform === 'darwin' && process.env.DSH_DESKTOP_UNSIGNED !== '1') { await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'), 'primary')) } await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT)) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))) diff --git a/apps/desktop/scripts/prepare-runtime-patches.ts b/apps/desktop/scripts/prepare-runtime-patches.ts new file mode 100644 index 0000000000..83ebbbe457 --- /dev/null +++ b/apps/desktop/scripts/prepare-runtime-patches.ts @@ -0,0 +1,125 @@ +/** Stage explicitly selected Desktop patches without modifying the source workspace. */ +import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { isAbsolute, join, relative, resolve } from 'node:path' +import yaml from 'js-yaml' +import { valid } from 'semver' +import { DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES, DESKTOP_RUNTIME_PATCHES, type DesktopRuntimePatch } from './runtime-patch-policy.ts' + +/** Expected patch bytes and package version for one independent installation. */ +export interface PreparedRuntimePatch { + spec: string + name: string + version: string + path: string + hash: string +} + +/** Selected bytes and dependencies excluded from the Desktop runtime. */ +export interface PreparedRuntimePatches { + patches: PreparedRuntimePatch[] + workspaceOnly: string[] +} + +function patchIdentity(spec: string): { name: string; version: string } { + const separator = spec.lastIndexOf('@') + const name = spec.slice(0, separator) + const version = spec.slice(separator + 1) + if (separator <= 0 || valid(version) !== version) throw new Error(`desktop patches: expected an exact package version: ${spec}`) + return { name, version } +} + +function patchHash(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex') +} + +/** + * Stage selected patches and explicit overrides before Desktop resolves its temporary lockfile. + * Every root patch must be classified; shared bytes must match the root lockfile's pnpm 11 hash. + * @param projectRoot Disposable project containing pnpm-workspace.yaml, outside the repository root itself. + * @param repositoryRoot Source repository; its files remain read-only. + * @param policy Desktop patch decisions, including optional runtime-only files relative to the repository. + * @param overrides Explicit Desktop dependency constraints; patch versions never generate overrides. + * @returns Expected patch identities and hashes for lockfile and staged-file verification. + */ +export function prepareRuntimePatches( + projectRoot: string, repositoryRoot: string, + policy: Readonly> = DESKTOP_RUNTIME_PATCHES, + overrides: Readonly> = DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES, +): PreparedRuntimePatches { + if (resolve(projectRoot) === resolve(repositoryRoot)) throw new Error('desktop patches: destination must not be the source workspace') + const source = yaml.load(readFileSync(join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf8')) as { + patchedDependencies: Record + } + const sourceLock = yaml.load(readFileSync(join(repositoryRoot, 'pnpm-lock.yaml'), 'utf8')) as { + patchedDependencies: Record + } + for (const spec of Object.keys(source.patchedDependencies)) { + if (!Object.hasOwn(policy, spec)) throw new Error(`desktop patches: classify the workspace patch ${spec}`) + } + const selected: Array = [] + for (const [spec, rule] of Object.entries(policy)) { + const identity = patchIdentity(spec) + if (rule.scope !== 'runtime-only' && !Object.hasOwn(source.patchedDependencies, spec)) { + throw new Error(`desktop patches: stale workspace patch decision ${spec}`) + } + if (rule.scope === 'workspace-only') continue + const sourcePath = rule.scope === 'shared' ? source.patchedDependencies[spec]! : rule.path + const localPath = relative(resolve(repositoryRoot), resolve(repositoryRoot, sourcePath)) + if (isAbsolute(sourcePath) || localPath.startsWith('..') || isAbsolute(localPath)) { + throw new Error(`desktop patches: patch must be inside the repository: ${sourcePath}`) + } + const bytes = readFileSync(join(repositoryRoot, sourcePath)) + const hash = patchHash(bytes) + if (rule.scope === 'shared' && sourceLock.patchedDependencies[spec] !== hash) { + throw new Error(`desktop patches: shared patch hash differs from the root lockfile: ${spec}`) + } + selected.push({ spec, ...identity, path: `patches/desktop-${selected.length}.patch`, hash, bytes }) + } + const targetPath = join(projectRoot, 'pnpm-workspace.yaml') + const target = yaml.load(readFileSync(targetPath, 'utf8')) as Record & { overrides?: Record } + for (const [name, version] of Object.entries(overrides)) { + if (target.overrides?.[name] !== undefined && target.overrides[name] !== version) { + throw new Error(`desktop patches: conflicting dependency override for ${name}`) + } + } + mkdirSync(join(projectRoot, 'patches'), { recursive: true }) + for (const entry of selected) writeFileSync(join(projectRoot, entry.path), entry.bytes) + writeFileSync(targetPath, yaml.dump({ ...target, overrides: { ...target.overrides, ...overrides }, + patchedDependencies: Object.fromEntries(selected.map(entry => [entry.spec, entry.path])), allowUnusedPatches: false })) + return { patches: selected.map(({ bytes: _bytes, ...entry }) => entry), + workspaceOnly: Object.entries(policy).filter(([, rule]) => rule.scope === 'workspace-only').map(([spec]) => patchIdentity(spec).name) } +} + +/** + * Reject changed patch bytes, lock hashes or resolved versions before the frozen production install. + * @param projectRoot Independent Desktop installation containing its resolved lockfile. + * @param plan Expected identities and exclusions returned by patch preparation. + */ +export function verifyRuntimePatches(projectRoot: string, plan: PreparedRuntimePatches): void { + const lock = yaml.load(readFileSync(join(projectRoot, 'pnpm-lock.yaml'), 'utf8')) as { + patchedDependencies?: Record + packages?: Record + } + for (const patch of plan.patches) { + if (!Object.keys(lock.packages ?? {}).some(spec => spec.startsWith(`${patch.name}@`))) { + throw new Error(`desktop patches: selected patch has no runtime dependency: ${patch.spec}`) + } + if (patchHash(readFileSync(join(projectRoot, patch.path))) !== patch.hash) { + throw new Error(`desktop patches: staged patch bytes changed: ${patch.spec}`) + } + if (lock.patchedDependencies?.[patch.spec] !== patch.hash) { + throw new Error(`desktop patches: lockfile patch hash mismatch: ${patch.spec}`) + } + } + for (const [spec, entry] of Object.entries(lock.packages ?? {})) { + if (plan.workspaceOnly.some(name => spec.startsWith(`${name}@`))) { + throw new Error(`desktop patches: workspace-only dependency entered the runtime: ${spec}; review its patch scope`) + } + const candidates = plan.patches.filter(patch => spec.startsWith(`${patch.name}@`)) + if (candidates.length === 0) continue + if (!candidates.some(patch => (entry.version ?? spec.slice(patch.name.length + 1)) === patch.version)) { + throw new Error(`desktop patches: ${spec} has no matching patch; review its version and Desktop patch policy`) + } + } +} diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index 1b88f091bc..ba92fef0e6 100644 --- a/apps/desktop/scripts/prepare-runtime.ts +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -1,14 +1,15 @@ -/** Prepare the target Electron distribution and pinned pnpm CLI. */ +/** Prepare Desktop resources and publish their versions after every preparation stage succeeds. */ -import { packagingStep } from './packaging-step.mjs' import { execFileSync } from 'node:child_process' -import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { parseArgs } from 'node:util' import { downloadArtifact } from '@electron/get' import extractZip from 'extract-zip' -import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { readPrimaryRuntime } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' +import { desktopTargetPlatform, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { packagingStep } from './packaging-step.mjs' import { preparePrimaryRuntime } from './prepare-primary-runtime.ts' import { prepareDesktopCli } from './prepare-cli.ts' import { prepareCommandLink } from './prepare-command-link.ts' @@ -16,23 +17,10 @@ import { prepareCommandLink } from './prepare-command-link.ts' const BUILD_PATHS = resolveDesktopTargetBuildPaths() const RUNTIME_ROOT = BUILD_PATHS.runtime -function preparePnpm(): string { - const require = createRequire(import.meta.url) - const manifestPath = require.resolve('pnpm') - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { version?: unknown } - if (typeof manifest.version !== 'string') throw new Error('desktop runtime: pnpm manifest has no version') - const packageDir = dirname(manifestPath) - const destination = join(RUNTIME_ROOT, 'pnpm') - rmSync(destination, { recursive: true, force: true }) - cpSync(packageDir, destination, { recursive: true }) - return manifest.version -} +type DesktopTarget = ReturnType -async function main(): Promise { - const { values } = parseArgs({ options: { 'defer-primary-runtime-smoke': { type: 'boolean', default: false } } }) - const target = resolveDesktopBuildTarget() - const platform = target.startsWith('mac-') ? 'darwin' : 'win32' - const arch = target.endsWith('arm64') ? 'arm64' : 'x64' +/** Extract the target Electron distribution and return its embedded Node version. */ +async function prepareElectron({ platform, arch }: DesktopTarget): Promise { const require = createRequire(import.meta.url) const { version } = require('electron/package.json') as { version: string } const archive = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'download:electron', @@ -40,28 +28,42 @@ async function main(): Promise { rmSync(BUILD_PATHS.electron, { recursive: true, force: true }) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'extract:electron', () => extractZip(archive, { dir: BUILD_PATHS.electron })) const executable = join(BUILD_PATHS.electron, platform === 'win32' ? 'electron.exe' : 'Electron.app/Contents/MacOS/Electron') - const nodeVersion = execFileSync(executable, ['-p', 'process.versions.node'], { + return execFileSync(executable, ['-p', 'process.versions.node'], { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, }).trim() - const macosMinimumVersion = platform === 'darwin' ? execFileSync('/usr/libexec/PlistBuddy', - ['-c', 'Print LSMinimumSystemVersion', join(BUILD_PATHS.electron, 'Electron.app', 'Contents', 'Info.plist')], { encoding: 'utf8' }).trim() : undefined - rmSync(RUNTIME_ROOT, { recursive: true, force: true }) - mkdirSync(RUNTIME_ROOT, { recursive: true }) - const pnpmVersion = preparePnpm() +} + +function prepareCli({ platform, arch }: DesktopTarget): void { cpSync(join(import.meta.dirname, 'node-bin'), join(RUNTIME_ROOT, 'bin'), { recursive: true }) chmodSync(join(RUNTIME_ROOT, 'bin', 'node'), 0o755) + const cli = join(RUNTIME_ROOT, 'cli') + prepareDesktopCli(cli, platform) + if (platform === 'darwin') { + const minimumVersion = execFileSync('/usr/libexec/PlistBuddy', [ + '-c', 'Print LSMinimumSystemVersion', join(BUILD_PATHS.electron, 'Electron.app', 'Contents', 'Info.plist'), + ], { encoding: 'utf8' }).trim() + prepareCommandLink(cli, arch, minimumVersion) + } + cpSync(join(import.meta.dirname, '..', 'lib', 'command-manager-entry.js'), join(cli, 'command-manager.js')) + cpSync(join(import.meta.dirname, 'command-path.ps1'), join(cli, 'command-path.ps1')) +} + +async function main(): Promise { + const { values } = parseArgs({ options: { 'defer-primary-runtime-smoke': { type: 'boolean', default: false } } }) + const target = desktopTargetPlatform(resolveDesktopBuildTarget()) + rmSync(RUNTIME_ROOT, { recursive: true, force: true }) + mkdirSync(RUNTIME_ROOT, { recursive: true }) + const nodeVersion = await prepareElectron(target) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', + () => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })) + const { pnpm } = await readPrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')) + if (pnpm === undefined) throw new Error('desktop runtime: primary-runtime manifest has no pnpm version') + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', async () => { prepareCli(target) }) writeFileSync(join(RUNTIME_ROOT, 'versions.json'), `${JSON.stringify({ schemaVersion: 1, node: nodeVersion, - pnpm: pnpmVersion, + pnpm, }, undefined, 2)}\n`) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', - async () => prepareDesktopCli(join(RUNTIME_ROOT, 'cli'), platform)) - if (macosMinimumVersion !== undefined) prepareCommandLink(join(RUNTIME_ROOT, 'cli'), arch, macosMinimumVersion) - cpSync(join(import.meta.dirname, '..', 'lib', 'command-manager-entry.js'), join(RUNTIME_ROOT, 'cli', 'command-manager.js')) - cpSync(join(import.meta.dirname, 'command-path.ps1'), join(RUNTIME_ROOT, 'cli', 'command-path.ps1')) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', - () => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })) } await main() diff --git a/apps/desktop/scripts/prepare-windows-ci.d.mts b/apps/desktop/scripts/prepare-windows-ci.d.mts new file mode 100644 index 0000000000..3b0196dc57 --- /dev/null +++ b/apps/desktop/scripts/prepare-windows-ci.d.mts @@ -0,0 +1,10 @@ +/** Resolve the version used by the manual Windows packaging workflow. */ + +/** + * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. + * @param environment Manual version and GitHub run identity. + * @param productVersion Version declared by the selected checkout. + * @param now Time used for the UTC date segment. + * @returns Validated build version. + */ +export function windowsCiVersion(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): string diff --git a/apps/desktop/scripts/prepare-windows-ci.mjs b/apps/desktop/scripts/prepare-windows-ci.mjs new file mode 100644 index 0000000000..5d00dbd415 --- /dev/null +++ b/apps/desktop/scripts/prepare-windows-ci.mjs @@ -0,0 +1,52 @@ +/** Resolve a manual Windows build version and validate the selected machine preset. */ +import { appendFileSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs' +import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' + +/** + * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. + * @param {NodeJS.ProcessEnv} environment Manual version and GitHub run identity. + * @param {string} productVersion Version declared by the selected checkout. + * @param {Date} now Time used for the UTC date segment. + * @returns {string} Validated build version. + */ +export function windowsCiVersion(environment, productVersion, now = new Date()) { + const requested = environment.PACKAGE_BUILD_VERSION?.trim() + const date = now.toISOString().slice(0, 10).replaceAll('-', '') + const example = `${desktopBuildVersionPrefix(productVersion)}${date}.1` + if (!requested) throw new Error(`Build version is required: use auto, ${productVersion}, or e.g. ${example}`) + let candidate = requested + if (requested === 'auto') { + for (const name of ['GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT']) { + if (!/^[1-9]\d*$/u.test(environment[name] ?? '')) throw new Error(`${name} must be a positive integer`) + } + candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` + } + try { + return validateDesktopBuildVersion(candidate, productVersion) + } catch { + throw new Error(`Invalid build version for this checkout: use auto, ${productVersion}, or e.g. ${example}`) + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + const appRoot = resolve(import.meta.dirname, '..') + const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) + const version = windowsCiVersion(process.env, product.version) + const mode = process.env.PACKAGE_SIGNING + if (mode !== 'unsigned' && mode !== 'signed') throw new Error('Select unsigned or signed packaging') + if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset') + const environment = loadDesktopPackageEnvironment('win32', process.env, appRoot, { unsigned: mode === 'unsigned' }) + // Machine-owned secrets are not automatically known to the Actions log masker. + for (const [name, value] of Object.entries(environment)) { + if (/TOKEN|PASSWORD|SECRET/iu.test(name) && value) { + const escaped = value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A') + process.stdout.write(`::add-mask::${escaped}\n`) + } + } + validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' }) + if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) +} diff --git a/apps/desktop/scripts/runtime-file-policy.ts b/apps/desktop/scripts/runtime-file-policy.ts index 1bc16b2a1c..14b0e2a186 100644 --- a/apps/desktop/scripts/runtime-file-policy.ts +++ b/apps/desktop/scripts/runtime-file-policy.ts @@ -1,7 +1,7 @@ /** Desktop-only omissions from already installed production npm packages. */ /** - * Identify build and diagnostic files omitted from the immutable Desktop runtime. + * Identify build, diagnostic and other-platform files omitted from the immutable Desktop runtime. * Unrecognized assets and target runtime binaries are retained. Paths name the copied * node_modules tree, including nested package containers. * @param path - Path relative to the production node_modules directory. @@ -16,7 +16,7 @@ export function desktopRuntimeFileExclusion( if (parts.some(part => ['.bin', '.pnpm', '.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(part))) { return 'package-manager metadata' } - const file = parts.at(-1) ?? '' + const file = parts.at(-1)! if (/\.(?:[cm]?[jt]s|css)\.map$/u.test(file)) return 'source map' if (/\.d\.[cm]?ts$/u.test(file)) return 'TypeScript declaration' if (/\.tsbuildinfo$/u.test(file)) return 'TypeScript build cache' @@ -34,6 +34,10 @@ export function desktopRuntimeFileExclusion( return 'fs-ext build configuration' } if (name === '@mixmark-io/domino' && (entry === 'test' || entry.startsWith('test/'))) return 'Domino test fixtures' + if (name === '@mixmark-io/domino' && (entry === '.yarn' || entry.startsWith('.yarn/'))) return 'Domino development tooling' + if (name === 'node-pty' && target.platform !== 'win32' && /^third_party\/conpty(?:\/|$)/u.test(entry)) { + return 'node-pty Windows ConPTY sources' + } if (name === 'node-pty' && entry.startsWith('prebuilds/')) { const platform = packageParts[nameParts + 1] if (platform !== undefined && platform !== `${target.platform}-${target.arch}`) return 'node-pty other platform' diff --git a/apps/desktop/scripts/runtime-patch-policy.ts b/apps/desktop/scripts/runtime-patch-policy.ts new file mode 100644 index 0000000000..f688db356d --- /dev/null +++ b/apps/desktop/scripts/runtime-patch-policy.ts @@ -0,0 +1,24 @@ +/** Desktop-specific selection of workspace and runtime-only dependency patches. */ + +/** A patch shared with the workspace, excluded from runtime installation, or supplied only to Desktop. */ +export type DesktopRuntimePatch = + | { scope: 'shared'; reason: string } + | { scope: 'workspace-only'; reason: string } + | { scope: 'runtime-only'; path: string; reason: string } + +/** Every workspace patch requires an explicit Desktop decision; runtime-only entries may add or replace a patch. */ +export const DESKTOP_RUNTIME_PATCHES: Readonly> = { + '@earendil-works/pi-ai@1.0.2': { scope: 'shared', reason: 'Runtime providers need the streamed argument parsing fix.' }, + '@electron/osx-sign@1.3.3': { scope: 'workspace-only', reason: 'Signing executes on the build host.' }, + '@fortune-sheet/core@1.0.4': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' }, + '@fortune-sheet/react@1.0.4': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' }, + '@yao-pkg/pkg@6.21.0': { scope: 'workspace-only', reason: 'The executable packer executes on the build host.' }, + 'exceljs@4.4.0': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' }, + 'node-pty@1.2.0-beta.15': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' }, +} + +/** Reviewed Desktop dependency constraints, independent of patch selection. */ +export const DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES: Readonly> = { + // The provider declares ^1.0.2; its patch is qualified against 1.0.2. + '@earendil-works/pi-ai@^1.0.2': '1.0.2', +} diff --git a/apps/desktop/scripts/setup-windows-packaging.ps1 b/apps/desktop/scripts/setup-windows-packaging.ps1 new file mode 100644 index 0000000000..9e686fb2fe --- /dev/null +++ b/apps/desktop/scripts/setup-windows-packaging.ps1 @@ -0,0 +1,49 @@ +# Prepare or inspect machine-owned Windows packaging directories without reading secret values. +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$Root, + [switch]$CheckOnly +) +$ErrorActionPreference = 'Stop' +if ($Root -notmatch '^[A-Za-z]:[\\/]') { throw 'Root must be an absolute local drive directory' } +$rootPath = [IO.Path]::GetFullPath($Root) +$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($rootPath)) +if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'Packaging root requires a fixed local drive' } +for ($ancestor = [IO.DirectoryInfo]::new($rootPath); $null -ne $ancestor; $ancestor = $ancestor.Parent) { + if ($ancestor.Exists -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Packaging root cannot contain linked directories' } +} +$identity = [Security.Principal.WindowsIdentity]::GetCurrent().User +$allowed = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544') +foreach ($relative in @('packaging', 'packaging/presets', 'packaging/secrets', 'packaging/certificates', 'cache', 'cache/windows-signatures')) { + $path = Join-Path $rootPath $relative + if (-not (Test-Path -LiteralPath $path)) { + if ($CheckOnly) { throw "Missing directory: $path" } + $null = New-Item -ItemType Directory -Path $path + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($identity) + $acl.SetAccessRuleProtection($true, $false) + foreach ($sid in $allowed) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $null = $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $path -AclObject $acl + } + $item = Get-Item -LiteralPath $path + if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Not a real directory: $path" } + $acl = Get-Acl -LiteralPath $path + if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $identity.Value) { throw "Directory must belong to current account: $path" } + foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) { + if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin $allowed) { throw "Directory permits another account: $path" } + } +} +if (-not $CheckOnly) { + $templates = Join-Path $PSScriptRoot '../config/windows-packaging' + foreach ($name in @('test', 'production')) { + $destination = Join-Path $rootPath "packaging/presets/$name.env.example" + if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates "$name.env.example") -Destination $destination } + } + $destination = Join-Path $rootPath 'packaging/secrets/windows-signing.env.example' + if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates 'windows-signing.env.example') -Destination $destination } +} +Write-Output "Packaging directories verified. Configuration root: $(Join-Path $rootPath 'packaging')" +Write-Output 'Existing configuration, caches, runner registration and signing failure records were not overwritten.' diff --git a/apps/desktop/scripts/smoke-prepared-runtime.ts b/apps/desktop/scripts/smoke-prepared-runtime.ts index 697f52708e..e6afd574da 100644 --- a/apps/desktop/scripts/smoke-prepared-runtime.ts +++ b/apps/desktop/scripts/smoke-prepared-runtime.ts @@ -31,7 +31,16 @@ export async function smokePreparedRuntime( const { stdout } = await promisify(execFile)(node, [ '--expose-internals', resolve(import.meta.dirname, '../tests/fixtures/runtime-payload-smoke.mjs'), root, resourcesRuntime, ], { timeout: 120_000, windowsHide: true, - env: desktopNodeEnvironment(node, join(resourcesRuntime, 'bin'), environment) }) + env: desktopNodeEnvironment(node, join(resourcesRuntime, 'bin'), environment) }).catch((error: unknown) => { + if (error instanceof Error) { + if ('stdout' in error && typeof error.stdout === 'string') process.stdout.write(error.stdout) + if ('stderr' in error && typeof error.stderr === 'string') process.stderr.write(error.stderr) + if ('killed' in error && 'signal' in error) { + process.stderr.write(`runtime payload: killed=${String(error.killed)}, signal=${String(error.signal)}\n`) + } + } + throw error + }) process.stdout.write(stdout) await smokeDesktopRuntime(root, node, descriptor, environment, resourcesRuntime) } finally { diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index f15f10a928..bbc2d3e7ae 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -1,6 +1,7 @@ /** Boot the materialized target runtime without access to a user's Harness profile. */ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { execFile } from 'node:child_process' @@ -12,7 +13,7 @@ import { createPluginProfile } from '../src/project-manager.ts' import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' /** - * Check Host startup, its matching frontend, external plugins and real Office-to-PDF conversion. + * Check CLI package operations, Host startup, its frontend, external plugins and real Office-to-PDF conversion. * @param root - Materialized dsh resources. * @param node - Prepared target Electron executable. * @param runtime - Verified resource descriptor. @@ -25,15 +26,50 @@ export async function smokeDesktopRuntime( ): Promise { const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-')) const profile = join(home, 'profiles', 'desktop') - const host = new DesktopHostProcess(node, root, profile, undefined, { ...environment, DSH_HOME: home }, + const hostEnvironment = { + ...Object.fromEntries(Object.entries(environment).filter(([name]) => !/^(?:npm|pnpm|corepack)_/iu.test(name))), + DSH_HOME: home, HOME: home, USERPROFILE: home, + XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, XDG_STATE_HOME: home, + APPDATA: home, LOCALAPPDATA: home, + npm_config_userconfig: join(home, 'npmrc'), npm_config_globalconfig: join(home, 'npmrc'), + npm_config_store_dir: join(home, 'store'), npm_config_enable_global_virtual_store: 'false', + } + const host = new DesktopHostProcess(node, root, profile, undefined, hostEnvironment, undefined, join(resourcesRuntime, 'primary-runtime'), - { pnpm: join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.cjs'), nodeBin: join(resourcesRuntime, 'bin') }) + { pnpm: join(resourcesRuntime, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'), nodeBin: join(resourcesRuntime, 'bin') }) let timer: ReturnType | undefined try { + writeFileSync(join(home, 'npmrc'), '') createPluginProfile(profile) const pluginName = 'desktop-runtime-smoke-plugin' - const plugin = join(profile, 'node_modules', pluginName) + const plugin = join(home, pluginName) mkdirSync(plugin, { recursive: true }) + const installedName = 'desktop-installed-smoke-plugin' + const installedPlugin = join(home, installedName) + mkdirSync(installedPlugin) + writeFileSync(join(installedPlugin, 'package.json'), JSON.stringify({ + name: installedName, version: '1.0.0', type: 'module', exports: './index.js', dsh: { bundle: { patch: './bundle.yml' } }, + })) + writeFileSync(join(installedPlugin, 'index.js'), ` +export function apply(ctx) { + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-installed', + handler(_request, response) { response.end('installed plugin ready') } })) +} +`) + writeFileSync(join(installedPlugin, 'bundle.yml'), `- insert:\n - id: ${installedName}\n name: ${installedName}\n inject: [webServer]\n`) + appendFileSync(join(profile, 'pnpm-workspace.yaml'), 'offline: true\nupdateNotifier: false\n') + const cli = join(root, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js') + const runCli = (args: string[]) => promisify(execFile)(node, + ['--expose-internals', cli, 'plugin', '--profile', 'desktop', ...args], + { cwd: home, env: { ...hostEnvironment, ELECTRON_RUN_AS_NODE: '1' }, timeout: 120_000, windowsHide: true }) + await runCli(['add', `file:${installedPlugin}`, '--offline', '--ignore-scripts']) + const installedEntry = join(profile, 'node_modules', installedName, 'index.js') + if (readFileSync(installedEntry, 'utf8') !== readFileSync(join(installedPlugin, 'index.js'), 'utf8')) { + throw new Error('desktop runtime: CLI did not install the local plugin') + } + await runCli(['remove', installedName]) + if (existsSync(installedEntry)) throw new Error('desktop runtime: CLI did not remove the local plugin') + console.log('desktop runtime: CLI offline plugin installation and removal passed') const primary = join(resourcesRuntime, 'primary-runtime') const dependencies = workspaceDependencyPaths(primary, await readPrimaryRuntime(primary)) await promisify(execFile)(dependencies.python, ['-I', '-B', @@ -56,10 +92,19 @@ export function apply(ctx) { if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke', handler(_request, response) { response.end('plugin route ready') } })) + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-install', + async handler(_request, response) { + try { + response.end(JSON.stringify(await ctx.pluginManager.installBundle(${JSON.stringify(installedPlugin)}))) + } catch (error) { + response.statusCode = 500 + response.end(inspect(error, { depth: 5 })) + } + } })) ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office-cli', async handler(_request, response) { try { - const skill = await ctx.skills.get('office-docx') + const skill = await ctx.skills.get('office-export-pdf') const json = skill?.content.match(/\\n(\\{\\n[\\s\\S]+)$/u)?.[1] if (json === undefined) throw new Error('Office skill did not supply CLI paths') const { libreofficeKit: { node, cli } } = JSON.parse(json) @@ -90,12 +135,13 @@ export function apply(ctx) { } } `) - writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills]\n') + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills, pluginManager]\n') + cpSync(plugin, join(profile, 'node_modules', pluginName), { recursive: true }) const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { - dependencies: Record + dependencies?: Record dsh: { profile: { bundles: string[] } } } - manifest.dependencies[pluginName] = '1.0.0' + manifest.dependencies = { ...manifest.dependencies, [pluginName]: `file:${plugin}` } manifest.dsh.profile.bundles.push(pluginName) writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest)) writeFileSync(join(profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n') @@ -111,6 +157,14 @@ export function apply(ctx) { } const pluginResponse = await fetch(new URL('/desktop-smoke', ready.url), { headers: { cookie } }) if (await pluginResponse.text() !== 'plugin route ready') throw new Error('desktop runtime: plugin HTTP route failed') + const installation = await fetch(new URL('/desktop-smoke-install', ready.url), { + headers: { cookie }, signal: AbortSignal.timeout(120_000), + }) + const installationResult = await installation.text() + const installed = await fetch(new URL('/desktop-smoke-installed', ready.url), { headers: { cookie } }) + if (!installation.ok || !installed.ok || await installed.text() !== 'installed plugin ready') { + throw new Error(`desktop runtime: bundled pnpm plugin installation failed: ${installationResult}`) + } for (const { extension } of inputs) { const converted = await fetch(new URL(`/desktop-smoke-office/${extension}`, ready.url), { headers: { cookie }, signal: AbortSignal.timeout(120_000), @@ -133,7 +187,8 @@ export function apply(ctx) { console.log('desktop runtime: DOCX, XLSX, PPTX to PDF and skill CLI discovery passed') } finally { clearTimeout(timer) - await host.stop() - rmSync(home, { recursive: true, force: true }) + try { await host.stop() } finally { + await rm(home, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) + } } } diff --git a/apps/desktop/scripts/start-windows-packaging-runner.ps1 b/apps/desktop/scripts/start-windows-packaging-runner.ps1 new file mode 100644 index 0000000000..13251a110a --- /dev/null +++ b/apps/desktop/scripts/start-windows-packaging-runner.ps1 @@ -0,0 +1,38 @@ +# Start the existing registered runner with explicit machine configuration and a scrubbed environment. +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [switch]$CheckOnly) +$ErrorActionPreference='Stop' +$pwshRoot=Join-Path $RunnerRoot 'tools/pwsh-7.6.6' +$runnerPath = $pwshRoot + ';' + [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') +$keep=@('SystemRoot','windir','SystemDrive','ComSpec','TEMP','TMP','USERPROFILE','HOMEDRIVE','HOMEPATH','LOCALAPPDATA','APPDATA','ProgramData','ProgramFiles','ProgramFiles(x86)','CommonProgramFiles','CommonProgramFiles(x86)','CommonProgramW6432','ProgramW6432','PROCESSOR_ARCHITECTURE','NUMBER_OF_PROCESSORS','OS','PATHEXT','USERNAME','USERDOMAIN','COMPUTERNAME','PUBLIC','ALLUSERSPROFILE') +foreach($entry in @(Get-ChildItem Env:)) { + if($entry.Name -notin $keep) { [Environment]::SetEnvironmentVariable($entry.Name,$null,'Process') } +} +$env:PATH=$runnerPath +if (-not [IO.Path]::IsPathRooted($ConfigRoot) -or -not (Test-Path -LiteralPath $ConfigRoot -PathType Container)) { throw 'ConfigRoot must identify an existing absolute directory' } +if ($ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'ConfigRoot requires an absolute local drive directory' } +$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot([IO.Path]::GetFullPath($ConfigRoot))) +if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a fixed local drive' } +$env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot) +$env:DSH_TELEMETRY_DISABLED='1' +$env:POWERSHELL_TELEMETRY_OPTOUT='1' +foreach($tool in @('pwsh','node','git','tar','python')) { + $resolved=Get-Command $tool -CommandType Application -ErrorAction Stop | Select-Object -First 1 + Write-Output ('Tool: '+$tool+' -> '+$resolved.Source) +} +$env:PYTHON=(Get-Command python -CommandType Application | Select-Object -First 1).Source +& pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' +if($LASTEXITCODE -ne 0){throw 'PowerShell probe failed'} +& node --version +if($LASTEXITCODE -ne 0){throw 'Node probe failed'} +& python --version +if($LASTEXITCODE -ne 0){throw 'Python probe failed'} +$vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' +$visualStudio=& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if($LASTEXITCODE -ne 0 -or -not $visualStudio){throw 'Visual C++ tools unavailable'} +Write-Output 'Visual C++ tools located.' +if($CheckOnly){Write-Output 'Runner environment check passed.'; exit 0} +if(Get-Process -Name Runner.Listener -ErrorAction SilentlyContinue){throw 'A runner listener is already running; inspect before starting another.'} +Set-Location $RunnerRoot +& .\run.cmd +exit $LASTEXITCODE diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 848f586d06..1202670a3e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -155,7 +155,7 @@ function runtimeResources(): RuntimeResources { const nodeBin = development ? join(app.getAppPath(), 'scripts', 'node-bin') : join(process.resourcesPath, 'runtime', 'bin') const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined) ?? (development ? join(app.getAppPath(), 'node_modules', 'pnpm', 'bin', 'pnpm.mjs') - : join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs')) + : join(process.resourcesPath, 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')) const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined) ?? (development ? join(app.getAppPath(), '.desktop-build', 'development', 'project') : join(app.getAppPath(), 'dsh')) return { node, nodeBin, pnpm, dsh } @@ -709,14 +709,14 @@ async function main(): Promise { return browserGuests.release(event.sender, lease) }) - session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => { + session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*', 'wss://127.0.0.1/*'] }, (details, callback) => { if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) { callback({}) return } const target = new URL(hostUrl) const requested = new URL(details.url) - if (requested.host !== target.host) { callback({}); return } + if (requested.host !== target.host || requested.protocol !== (target.protocol === 'https:' ? 'wss:' : 'ws:')) { callback({}); return } const headers = Object.fromEntries(Object.entries(details.requestHeaders).map(([name, value]) => [name.toLowerCase(), value])) if (headers.origin !== 'dsh-app://app') { callback({ cancel: true }); return } callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } }) diff --git a/apps/desktop/tests/desktop-cli.spec.ts b/apps/desktop/tests/desktop-cli.spec.ts new file mode 100644 index 0000000000..e358df8657 --- /dev/null +++ b/apps/desktop/tests/desktop-cli.spec.ts @@ -0,0 +1,37 @@ +/** The installed CLI uses the shared pnpm distribution with Electron's package-operation environment. */ + +import { delimiter, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { runCli } from '@deepseek-ai/dsh/lib/bin.js' +import { runDesktopCli } from '../../desktop-host/src/cli.ts' +import { installOfficeEngineResolution } from '../../desktop-host/src/office-engine.ts' + +vi.mock('@deepseek-ai/dsh/lib/bin.js', () => ({ runCli: vi.fn() })) +vi.mock('../../desktop-host/src/office-engine.ts', () => ({ installOfficeEngineResolution: vi.fn() })) + +afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() +}) + +it.each(['system-bin', undefined])('dispatches with bundled pnpm when PATH is %s', async (path) => { + vi.stubEnv('PATH', path) + const runtime = join('Application 中文 with spaces', 'resources', 'app.asar', 'dsh') + const support = join('Application 中文 with spaces', 'resources', 'runtime') + + await runDesktopCli(runtime, support) + + expect(installOfficeEngineResolution).toHaveBeenCalledExactlyOnceWith(runtime) + expect(runCli).toHaveBeenCalledExactlyOnceWith({ + manageDesktopProfile: true, + packageManager: { + command: process.execPath, + args: ['--expose-internals', join(support, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')], + env: { + ELECTRON_RUN_AS_NODE: '1', + DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, + PATH: `${join(support, 'bin')}${delimiter}${path ?? ''}`, + }, + }, + }) +}) diff --git a/apps/desktop/tests/fixtures/pnpm-smoke.mjs b/apps/desktop/tests/fixtures/pnpm-smoke.mjs new file mode 100644 index 0000000000..5d16769ab4 --- /dev/null +++ b/apps/desktop/tests/fixtures/pnpm-smoke.mjs @@ -0,0 +1,69 @@ +/** Install and use a local dependency with the packaged pnpm and Electron Node launcher. */ + +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' + +/** + * Exercise offline installation and package scripts without a system pnpm or Node installation. + * @param {string} resourcesRuntime - Packaged runtime directory outside ASAR. + * @returns {Promise} Resolves after execution and removal of the private installation. + */ +export async function checkPnpm(resourcesRuntime) { + assert.ok(process.versions.electron, 'Run the package-manager smoke with Electron Node mode') + assert.equal(existsSync(join(resourcesRuntime, 'pnpm')), false, 'Desktop must ship only primary-runtime pnpm') + const primary = join(resourcesRuntime, 'primary-runtime') + const pnpm = join(primary, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs') + const versions = JSON.parse(readFileSync(join(resourcesRuntime, 'versions.json'), 'utf8')) + const manifest = JSON.parse(readFileSync(join(primary, 'runtime.json'), 'utf8')) + assert.equal(versions.pnpm, manifest.pnpm) + const scratch = mkdtempSync(join(tmpdir(), 'desktop-pnpm-smoke-')) + try { + mkdirSync(join(scratch, 'dependency')) + writeFileSync(join(scratch, 'dependency', 'package.json'), JSON.stringify({ + name: 'desktop-pnpm-smoke-dependency', version: '1.0.0', main: 'index.cjs', + })) + writeFileSync(join(scratch, 'dependency', 'index.cjs'), 'exports.answer = 42\n') + writeFileSync(join(scratch, 'package.json'), JSON.stringify({ + name: 'desktop-node-script-smoke', private: true, + dependencies: { 'desktop-pnpm-smoke-dependency': 'file:./dependency' }, scripts: { check: 'node check.cjs' }, + })) + writeFileSync(join(scratch, 'npmrc'), '') + writeFileSync(join(scratch, 'check.cjs'), ` +const assert = require('node:assert/strict') +assert.equal(require('desktop-pnpm-smoke-dependency').answer, 42) +assert.equal(process.execPath, ${JSON.stringify(process.execPath)}) +assert.ok(process.versions.electron) +assert.ok(process.execArgv.includes('--expose-internals')) +assert.equal(typeof require('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') +console.log('desktop-node-script-ok') +`) + const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir|comspec|LD_LIBRARY_PATH)$/iu.test(name))) + const systemBin = process.platform === 'win32' ? join(process.env.SystemRoot, 'System32') : '/usr/bin:/bin' + const run = args => execFileSync(process.execPath, ['--expose-internals', pnpm, ...args], { + cwd: scratch, encoding: 'utf8', timeout: 45_000, windowsHide: true, + env: { ...environment, pnpm_config_verify_deps_before_run: 'false', + ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, + PATH: `${join(resourcesRuntime, 'bin')}${delimiter}${systemBin}`, + HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch, + XDG_CONFIG_HOME: scratch, XDG_CACHE_HOME: scratch, XDG_STATE_HOME: scratch, + npm_config_userconfig: join(scratch, 'npmrc'), npm_config_globalconfig: join(scratch, 'npmrc'), + npm_config_store_dir: join(scratch, 'store'), npm_config_enable_global_virtual_store: 'false', + npm_config_update_notifier: 'false' }, + }) + assert.equal(run(['--version']).trim(), versions.pnpm) + run(['install', '--offline', '--ignore-scripts']) + assert.match(run(['run', 'check']), /desktop-node-script-ok/u) + } finally { + await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) + } +} + +if (import.meta.main) { + assert.ok(process.argv[2], 'Pass the packaged runtime directory') + await checkPnpm(process.argv[2]) + console.log('desktop pnpm: offline installation and Electron Node package script passed') +} diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs index 503c4bae3c..3cda38c620 100644 --- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -8,6 +8,7 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { delimiter, dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { checkPnpm } from './pnpm-smoke.mjs' const runtime = process.argv[2] assert.ok(runtime, 'Pass the filtered resources/dsh directory') @@ -20,33 +21,6 @@ const resourcesRuntime = process.argv[3] ?? join(dirname(root), 'runtime') const requireRuntime = createRequire(join(root, 'package.json')) const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-')) -/** Run a package script with only the shipped node launcher available on PATH. */ -function checkPnpm() { - const bin = join(resourcesRuntime, 'bin') - const pnpm = join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.mjs') - writeFileSync(join(scratch, 'package.json'), JSON.stringify({ - name: 'desktop-node-script-smoke', private: true, scripts: { check: 'node check.cjs' }, - })) - writeFileSync(join(scratch, 'check.cjs'), ` -const assert = require('node:assert/strict') -assert.equal(process.execPath, ${JSON.stringify(process.execPath)}) -assert.ok(process.versions.electron) -assert.ok(process.execArgv.includes('--expose-internals')) -assert.equal(typeof require('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') -console.log('desktop-node-script-ok') -`) - const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir|comspec)$/iu.test(name))) - const systemBin = process.platform === 'win32' ? join(process.env.SystemRoot, 'System32') : '/usr/bin:/bin' - // This dependency-free fixture checks script launch, without pnpm's implicit install and update-network check. - const output = execFileSync(process.execPath, ['--expose-internals', pnpm, 'run', 'check'], { - cwd: scratch, encoding: 'utf8', timeout: 45_000, - env: { ...environment, pnpm_config_verify_deps_before_run: 'false', - ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, - PATH: `${bin}${delimiter}${systemBin}`, HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch }, - }) - assert.match(output, /desktop-node-script-ok/u) -} - /** Spawn only a fixed Node program and await the terminal's drained exit event. */ async function checkPty() { const pty = requireRuntime('node-pty') @@ -161,12 +135,19 @@ function checkHtml() { try { const builtin = requireRuntime('node-addon-require-builtin') assert.equal(typeof builtin.requireBuiltin('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') - checkPnpm() + console.error('runtime payload: pnpm') + await checkPnpm(resourcesRuntime) + console.error('runtime payload: koffi') checkKoffi() + console.error('runtime payload: sharp') await checkSharp() + console.error('runtime payload: html') checkHtml() + console.error('runtime payload: pty') await checkPty() + console.error('runtime payload: search') await checkSearch() + console.error('runtime payload: checks complete; awaiting process exit') } finally { // This private tree contains only fixture files; Windows may release handles after terminal exit. await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) diff --git a/apps/desktop/tests/fixtures/windows-pty-exit.cjs b/apps/desktop/tests/fixtures/windows-pty-exit.cjs new file mode 100644 index 0000000000..5aaa17a56e --- /dev/null +++ b/apps/desktop/tests/fixtures/windows-pty-exit.cjs @@ -0,0 +1,28 @@ +/** Exercise terminal cleanup in a child whose natural exit detects retained workers. */ +const assert = require('node:assert/strict') +const { createRequire } = require('node:module') +const { resolve } = require('node:path') +const pty = createRequire(resolve(__dirname, '../../../../packages/subprocess/subprocess-local/package.json'))('node-pty') +const killWhileRunning = process.argv[2] === 'kill' +const terminal = pty.spawn(process.env.ComSpec, ['/d', '/c', killWhileRunning ? 'echo pty-ready & pause >nul' : 'echo pty-complete'], { + cwd: __dirname, env: process.env, cols: 80, rows: 24, useConptyDll: true, +}) +let output = '' +let killed = false +terminal.onData(data => { + output += data + if (killWhileRunning && !killed && output.includes('pty-ready')) { + killed = true + terminal.kill() + } +}) +terminal.onExit(event => { + if (!killWhileRunning) { + assert.equal(event.exitCode, 0) + assert.match(output, /pty-complete/u) + terminal.kill() + } else { + assert.equal(killed, true) + } + process.once('beforeExit', () => console.log('pty-cleanup-complete')) +}) diff --git a/apps/desktop/tests/installed-update-builder.spec.ts b/apps/desktop/tests/installed-update-builder.spec.ts index 089b04deef..12e5a82258 100644 --- a/apps/desktop/tests/installed-update-builder.spec.ts +++ b/apps/desktop/tests/installed-update-builder.spec.ts @@ -88,8 +88,10 @@ describe('installed-update application inputs and builder configuration', () => expect(config.extraMetadata.main).toBe('qualification-bootstrap.mjs') expect(config.directories.output).toBe(join(manifest, '..', versions[index]!, 'installer')) const dsh = join(manifest, '..', versions[index]!, 'dsh') - expect(config.files[2]).toMatchObject({ from: dsh, to: 'dsh' }) - expect(config.files[3]).toMatchObject({ from: join(dsh, 'node_modules'), to: 'dsh/node_modules' }) + expect(config.files.at(-2)).toMatchObject({ from: dsh, to: 'dsh' }) + expect(config.files.at(-1)).toMatchObject({ from: join(dsh, 'node_modules'), to: 'dsh/node_modules' }) + expect(config.files).toContain('!node_modules/**/*.{js,cjs,mjs,ts,cts,mts,css}.map') + expect(config.files).toContain('!node_modules/**/*.tsbuildinfo') expect(config.win.forceCodeSigning).toBe(true) expect(config.win.signtoolOptions.publisherName).toBe('CN=Fixture,O=Fixture,C=CN') expect(typeof config.win.signtoolOptions.sign).toBe('function') diff --git a/apps/desktop/tests/installed-update/README.i18n.yaml b/apps/desktop/tests/installed-update/README.i18n.yaml index 1ac20fbb75..cfd4135420 100644 --- a/apps/desktop/tests/installed-update/README.i18n.yaml +++ b/apps/desktop/tests/installed-update/README.i18n.yaml @@ -15,8 +15,8 @@ en: bd61ff806d57cbb1 zh: 8b09719bc3b247c8 /windows-installed-update-walkthrough/prepare-materials: - en: 1f7d9426be57a23a - zh: 9f2be099d3d1a026 + en: ab2cf13a10671564 + zh: f2c944df56f1e8d7 /windows-installed-update-walkthrough/operator-sequence: en: 8a7f275812dbe11f zh: 24dbf32328f9c0dd diff --git a/apps/desktop/tests/installed-update/README.md b/apps/desktop/tests/installed-update/README.md index b847960862..77c7ae460f 100644 --- a/apps/desktop/tests/installed-update/README.md +++ b/apps/desktop/tests/installed-update/README.md @@ -23,7 +23,7 @@ Prepare a private test application and fresh test distribution namespace, then l From the repository root with dependencies installed, allocate a run with two increasing derived test versions. This writes only an ignored local manifest; it does not build, sign, upload, install, or read credentials. -The examples use base version `0.1.6-alpha.1`. Before creating new material, substitute the actual base, Asia/Shanghai date, and unused index according to the [release version rules](../../README.md#release-versions). +The examples use base version `0.1.6-alpha.1`. Before creating new material, substitute the actual base, Asia/Shanghai date, and unused index according to the [release version rules](../../packaging/README.md#release-versions). ```powershell node --import tsx apps/desktop/scripts/prepare-installed-update.ts init 0.1.6-alpha.1.20260916.1 0.1.6-alpha.1.20260916.2 @@ -44,7 +44,7 @@ Package both files from the generated `bootstrap` directory beside `lib/`, and s The `application` command copies built main/preload modules, renderer files, and the prepared bootstrap into `application/files`, with SHA-256 values in `application/result.json`. It does not copy the app's root `.env.windows` or freeze `node_modules` and build tools. The [qualification builder configuration](../../scripts/installed-update-builder.ts) verifies this inventory and the selected private runtime, selects the shared frozen files and isolated package metadata, and preserves ordinary installer and signing hooks. Its tests substitute the signer and pass the pinned builder's configuration validator; they do not produce an installer. A separately authorized supervised build must still record dependency/tool inputs and verify final package contents and signatures. -The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. The shared builder requires a valid `DOWNLOAD_TEST_RELEASE_ID` from the [test release settings](../../README.md#upload-updates); qualification still publishes through the manifest’s separate `qualification/` paths. Run this from the repository root with one exact version: +The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. The shared builder requires a valid `DOWNLOAD_TEST_RELEASE_ID` from the [test release settings](../../publishing/README.md#upload-updates); qualification still publishes through the manifest’s separate `qualification/` paths. Run this from the repository root with one exact version: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check @@ -52,7 +52,7 @@ node --import tsx apps/desktop/scripts/package-installed-update.ts "" Actual packaging remains unverified and requires separate hardware-recovery approval and a present operator. The `--execute` mode requires a terminal and an exact confirmation containing the version and run ID; there is no piped approval option. It rechecks the interlock and exclusively allocates that version's `packaging` directory. The supervised child builds only that version, disables publication, strips unrelated credentials, and stops on failure or a 15-minute overall deadline. This deadline does not bound individual CSP authentication attempts. Records retain source/tool hashes, redacted output, events, and artifact file hashes; an existing attempt or output refuses reuse. `builderCompleted` and a successful supervisor result do not establish package verification, signature acceptance, or installation success; `packageVerification` remains `pending` until those checks are performed independently. -Before the operator starts, supply both verified installers, generated metadata and blockmaps, packaging and signature records, the exact installed executable path, and fixed test feed URL. Both packages must use the same test identity, isolated data directories, and absolute external `DSH_DESKTOP_UPDATE_JOURNAL_DIR` on every launch, including an installer-triggered restart. A variable set only in the original launch terminal is insufficient. The [Windows signing rules](../../README.md#windows-ev-signing) still apply; a manifest never clears a signing interlock. +Before the operator starts, supply both verified installers, generated metadata and blockmaps, packaging and signature records, the exact installed executable path, and fixed test feed URL. Both packages must use the same test identity, isolated data directories, and absolute external `DSH_DESKTOP_UPDATE_JOURNAL_DIR` on every launch, including an installer-triggered restart. A variable set only in the original launch terminal is insufficient. The [Windows signing rules](../../windows-signing/README.md#windows-ev-signing) still apply; a manifest never clears a signing interlock. The Windows [read-only signature helper](../../scripts/installed-update-signature.mjs) uses the real updater verifier with a publisher derived from the trusted public certificate, then requires valid Authenticode and timestamp attributes and unchanged SHA-512. Verification children receive no inherited signing/upload secrets or PowerShell module-path overrides. The helper never loads `.env.windows`, signs a file, or executes the inspected executable. Signed-probe acceptance and unsigned-installer rejection have been observed locally; they do not certify either prepared version, package identity, or installation behavior. diff --git a/apps/desktop/tests/installed-update/README.zh.md b/apps/desktop/tests/installed-update/README.zh.md index ac2bed4e2a..618eb3c578 100644 --- a/apps/desktop/tests/installed-update/README.zh.md +++ b/apps/desktop/tests/installed-update/README.zh.md @@ -23,7 +23,7 @@ description: "Windows 已安装应用更新的人工清单,覆盖下载失败 在已安装依赖的仓库根目录中,用两个递增的派生测试版本创建批次。这只写入已忽略的本地清单,不构建、签名、上传、安装或读取凭据。 -示例以 `0.1.6-alpha.1` 为基础版本。创建新物料前,按[发布版本规则](../../README.zh.md#release-versions)替换为实际基础版本、北京时间日期和未使用的序号。 +示例以 `0.1.6-alpha.1` 为基础版本。创建新物料前,按[发布版本规则](../../packaging/README.zh.md#release-versions)替换为实际基础版本、北京时间日期和未使用的序号。 ```powershell node --import tsx apps/desktop/scripts/prepare-installed-update.ts init 0.1.6-alpha.1.20260916.1 0.1.6-alpha.1.20260916.2 @@ -44,7 +44,7 @@ node --import tsx apps/desktop/scripts/prepare-installed-update.ts application " `application` 命令将已构建的主程序/预加载模块、界面文件和准备好的启动入口复制到 `application/files`,并在 `application/result.json` 中记录 SHA-256。它不复制应用根目录的 `.env.windows`,也不冻结 `node_modules` 与构建工具。[验收打包配置](../../scripts/installed-update-builder.ts)校验该清单与选定的独立运行时,选用共享冻结文件和隔离包元数据,并保留常规安装器与签名钩子。其测试替换签名器,并通过固定版本构建器的配置校验;测试不生成安装器。另行授权的受监督构建仍须记录依赖/工具输入,并验证最终包内容及签名。 -[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。共享构建器要求[测试发布配置](../../README.zh.md#upload-updates)中有效的 `DOWNLOAD_TEST_RELEASE_ID`;资格验收仍使用清单中独立的 `qualification/` 路径发布。在仓库根目录使用一个准确版本执行: +[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。共享构建器要求[测试发布配置](../../publishing/README.zh.md#upload-updates)中有效的 `DOWNLOAD_TEST_RELEASE_ID`;资格验收仍使用清单中独立的 `qualification/` 路径发布。在仓库根目录使用一个准确版本执行: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check @@ -52,7 +52,7 @@ node --import tsx apps/desktop/scripts/package-installed-update.ts "" 实际打包仍未验证,必须另行获得硬件恢复授权并有操作者在场。`--execute` 模式要求终端和包含版本、批次 ID 的准确确认,没有管道批准选项。它再次检查保护锁,并独占创建该版本的 `packaging` 目录。受监督子进程只构建该版本、禁用发布、移除无关凭据,失败或达到 15 分钟整体期限时停止。该期限不限制单次 CSP 内部认证尝试。记录保留源码/工具哈希、脱敏输出、事件和产物文件哈希;已有尝试或输出拒绝复用。`builderCompleted` 与监督程序成功结果不证明包验证、验签或安装成功;独立完成这些检查前,`packageVerification` 保持 `pending`。 -操作者开始前,提供两个已验证安装包、生成的元数据与 blockmap、打包和签名记录、准确的安装后可执行文件路径与固定 test feed URL。两个安装包必须使用相同的测试身份、隔离数据目录,并在每次启动时设置同一个安装目录外的绝对路径 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`,包括安装器触发的重启。仅在首次启动终端中设置变量是不够的。[Windows 签名规则](../../README.zh.md#windows-ev-signing)仍适用;清单绝不解除签名保护锁。 +操作者开始前,提供两个已验证安装包、生成的元数据与 blockmap、打包和签名记录、准确的安装后可执行文件路径与固定 test feed URL。两个安装包必须使用相同的测试身份、隔离数据目录,并在每次启动时设置同一个安装目录外的绝对路径 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`,包括安装器触发的重启。仅在首次启动终端中设置变量是不够的。[Windows 签名规则](../../windows-signing/README.zh.md#windows-ev-signing)仍适用;清单绝不解除签名保护锁。 Windows [只读签名检查器](../../scripts/installed-update-signature.mjs)使用真实 updater 验签,发布者来自可信公钥证书,随后要求 Authenticode 与时间戳属性有效且 SHA-512 未变。验签子进程不继承签名/上传秘密或 PowerShell 模块路径覆盖。检查器绝不加载 `.env.windows`、签名文件或执行被检查的可执行文件。本地已观察到签名探针通过、未签名安装包被拒绝;这些结果不能证明任一准备版本、包身份或安装行为已验收。 diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index fdcb61600a..4ad63e41fd 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' @@ -25,6 +25,41 @@ it('signs Mach-O files in their final locations and verifies each signature', as expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity, undefined) expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) }) + +// File symlinks in these macOS fixtures require privileges unavailable to ordinary Windows processes. +it.skipIf(process.platform === 'win32')('signs a shared Python interpreter once and retains both executable aliases', async () => { + const path = root() + const bin = join(path, 'dependencies/python/bin') + mkdirSync(bin, { recursive: true }) + writeFileSync(join(path, 'runtime.json'), JSON.stringify({ desktopVersion: '1', platform: 'darwin', arch: 'arm64', python: '3.12.14', pythonPackages: {} })) + writeFileSync(join(bin, 'python3.12'), Buffer.from('cffaedfe00000000', 'hex')) + for (const name of ['python', 'python3']) symlinkSync('python3.12', join(bin, name)) + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64', undefined, 'primary')).resolves.toBe(1) + expect(signMacOSRuntimeCode).toHaveBeenCalledExactlyOnceWith(join(bin, 'python3.12'), expect.any(String), identity, undefined) + for (const name of ['python', 'python3']) expect(readlinkSync(join(bin, name))).toBe('python3.12') + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).rejects.toThrow('unsupported filesystem entry') +}) + +// The signer must inspect real file symlinks, including dangling and cyclic ones. +it.skipIf(process.platform === 'win32').each(['unknown', 'absolute', 'escaping', 'dangling', 'chained', 'directory'])('rejects a %s runtime link before signing any file', async (kind) => { + const path = root() + const bin = join(path, 'dependencies/python/bin') + mkdirSync(bin, { recursive: true }) + writeFileSync(join(path, 'runtime.json'), JSON.stringify({ desktopVersion: '1', platform: 'darwin', arch: 'arm64', python: '3.12.14', pythonPackages: {} })) + const canonical = join(bin, 'python3.12') + if (kind === 'directory') mkdirSync(canonical) + else if (kind !== 'dangling') writeFileSync(canonical, Buffer.from('cffaedfe00000000', 'hex')) + if (kind === 'chained') { + rmSync(canonical) + symlinkSync('python3', canonical) + } + const target = kind === 'absolute' ? canonical : kind === 'escaping' ? '../outside' : 'python3.12' + symlinkSync(target, join(bin, kind === 'unknown' ? 'unlisted' : 'python3')) + const result = signMacOSRuntime(path, 'com.example.app', identity, 'arm64', undefined, 'primary') + if (kind === 'dangling') await expect(result).rejects.toMatchObject({ code: 'ENOENT', path: canonical }) + else await expect(result).rejects.toThrow(kind === 'unknown' ? 'unsupported filesystem entry' : 'invalid Python alias') + expect(signMacOSRuntimeCode).not.toHaveBeenCalled() +}) it('awaits other signers before rejecting and permitting output cleanup', async () => { const path = root() for (const name of ['a.node', 'b.node']) writeFileSync(join(path, name), Buffer.from('cffaedfe00000000', 'hex')) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index d39ee99cb9..186b597b07 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -93,9 +93,9 @@ describe('desktop macOS release signature', () => { const ignored = (path: string): boolean => config.mac.signIgnore.some(pattern => new RegExp(pattern).test(path)) expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/en.lproj/locale.pak')).toBe(true) expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/resources.pak')).toBe(true) + expect(ignored('/App.app/Contents/Resources/runtime/primary-runtime/dependencies/pnpm/addon.node')).toBe(true) for (const path of [ '/App.app/Contents/Resources/runtime/node/node', - '/App.app/Contents/Resources/runtime/pnpm/addon.node', '/App.app/Contents/Frameworks/Electron.framework/Versions/A/library.dylib', '/App.app/Contents/Frameworks/Electron.framework', '/App.app', diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 097c8e5371..d9a8f3103b 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -443,6 +443,21 @@ afterEach(async () => { }) describe('desktop main startup', () => { + it.each([true, false])('selects the package manager before Host startup (packaged=%s)', async (packaged) => { + harness.app.isPackaged = packaged + await readyForUpdate() + const host = harness.hosts[0]! + expect(host.node).toBe(process.execPath) + expect(host.primaryRuntime).toBe(packaged + ? join('desktop-test-resources', 'runtime', 'primary-runtime') : 'test-primary-runtime') + expect(host.packageManager).toMatchObject({ + pnpm: packaged + ? join('desktop-test-resources', 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs') + : 'test-pnpm', + nodeBin: packaged ? join('desktop-test-resources', 'runtime', 'bin') : join('desktop-test-app', 'scripts', 'node-bin'), + }) + }) + it('starts the Host with authentication popups disabled when local settings are invalid', async () => { configureTestAuthPopup(false) const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') @@ -1019,11 +1034,12 @@ describe('desktop main startup', () => { } finally { harness.app.name = originalName } }) - it('attaches Host socket credentials only to the owned application origin and window', async () => { + it.each(['http:', 'https:'] as const)('attaches %s Host socket credentials only to the owned application origin and window', async (protocol) => { await import('../src/main.ts') await harness.preparing.promise harness.prepared.resolve() await harness.hostStarted.promise + harness.hosts[0]!.url = `${protocol}//127.0.0.1:3080/?token=test` harness.hosts[0]!.ready.resolve() await Promise.resolve(invoke(DESKTOP_IPC.boot)) const handler = harness.socketHeaders.mock.calls[0]![1] as ( @@ -1031,16 +1047,19 @@ describe('desktop main startup', () => { callback: (result: unknown) => void, ) => void const callback = vi.fn() - const details = { url: 'ws://127.0.0.1:3080/api/remote.mux', webContentsId: 42, requestHeaders: { Origin: 'dsh-app://app' } } + const socketProtocol = protocol === 'https:' ? 'wss:' : 'ws:' + const details = { url: `${socketProtocol}//127.0.0.1:3080/api/remote.mux`, webContentsId: 42, requestHeaders: { Origin: 'dsh-app://app' } } handler(details, callback) expect(callback).toHaveBeenLastCalledWith({ requestHeaders: { - origin: 'http://127.0.0.1:3080', cookie: 'test-cookie', 'sec-fetch-site': 'same-origin', + origin: `${protocol}//127.0.0.1:3080`, cookie: 'test-cookie', 'sec-fetch-site': 'same-origin', } }) handler({ ...details, requestHeaders: { Origin: 'https://other.example' } }, callback) expect(callback).toHaveBeenLastCalledWith({ cancel: true }) handler({ ...details, webContentsId: 43 }, callback) expect(callback).toHaveBeenLastCalledWith({}) - handler({ ...details, url: 'ws://127.0.0.1:9999/api/remote.mux' }, callback) + handler({ ...details, url: `${socketProtocol}//127.0.0.1:9999/api/remote.mux` }, callback) + expect(callback).toHaveBeenLastCalledWith({}) + handler({ ...details, url: `${socketProtocol === 'wss:' ? 'ws:' : 'wss:'}//127.0.0.1:3080/api/remote.mux` }, callback) expect(callback).toHaveBeenLastCalledWith({}) }) diff --git a/apps/desktop/tests/prepare-runtime-patches.spec.ts b/apps/desktop/tests/prepare-runtime-patches.spec.ts new file mode 100644 index 0000000000..951724e114 --- /dev/null +++ b/apps/desktop/tests/prepare-runtime-patches.spec.ts @@ -0,0 +1,140 @@ +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, expect, it } from 'vitest' +import yaml from 'js-yaml' +import { pnpmCommand } from '../../../scripts/release/process.ts' +import { prepareRuntimePatches, verifyRuntimePatches } from '../scripts/prepare-runtime-patches.ts' +import type { DesktopRuntimePatch } from '../scripts/runtime-patch-policy.ts' + +const roots: string[] = [] +const shared = { scope: 'shared', reason: 'Runtime fixture.' } as const +const excluded = { scope: 'workspace-only', reason: 'Build fixture.' } as const +const patch = 'diff --git a/value.txt b/value.txt\n--- a/value.txt\n+++ b/value.txt\n@@ -1 +1 @@\n-original\n+patched\n' +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) +}) + +function fixture(): { root: string; source: string; project: string } { + const root = mkdtempSync(join(tmpdir(), 'desktop-patch-policy-')) + roots.push(root) + const source = join(root, 'source') + const project = join(root, 'project') + mkdirSync(source) + mkdirSync(project) + writeFileSync(join(source, 'shared.patch'), patch) + writeFileSync(join(source, 'private.patch'), patch.replace('+patched', '+private')) + writeFileSync(join(source, 'pnpm-workspace.yaml'), yaml.dump({ patchedDependencies: { 'fixture@1.0.0': 'shared.patch' } })) + writeFileSync(join(source, 'pnpm-lock.yaml'), yaml.dump({ + patchedDependencies: { 'fixture@1.0.0': createHash('sha256').update(patch).digest('hex') }, + })) + writeFileSync(join(project, 'pnpm-workspace.yaml'), yaml.dump({ + packages: ['.'], nodeLinker: 'hoisted', overrides: { internal: 'file:./internal.tgz' }, allowBuilds: { fixture: false }, + })) + return { root, source, project } +} + +it('classifies the current workspace and shares only runtime patch bytes', () => { + const { project } = fixture() + const repository = resolve(import.meta.dirname, '../../..') + const before = readFileSync(join(repository, 'pnpm-lock.yaml')) + const plan = prepareRuntimePatches(project, repository) + expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.0.2', 'node-pty@1.2.0-beta.15']) + expect(plan.workspaceOnly).toHaveLength(5) + const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record } + expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined() + expect(settings.overrides['@earendil-works/pi-ai@^1.0.2']).toBe('1.0.2') + expect(readFileSync(join(repository, 'pnpm-lock.yaml'))).toEqual(before) +}) + +it('does not infer version overrides from selected patches', () => { + const { source, project } = fixture() + const before = readFileSync(join(source, 'pnpm-workspace.yaml')) + prepareRuntimePatches(project, source, { 'fixture@1.0.0': shared }, {}) + expect(yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8'))).toMatchObject({ + overrides: { internal: 'file:./internal.tgz' }, allowBuilds: { fixture: false }, + }) + expect(readFileSync(join(source, 'pnpm-workspace.yaml'))).toEqual(before) + expect(readFileSync(join(project, 'patches/desktop-0.patch'), 'utf8')).toBe(patch) +}) + +it('supports an independent runtime patch for a package also patched in the workspace', () => { + const { source, project } = fixture() + const plan = prepareRuntimePatches(project, source, { + 'fixture@1.0.0': { scope: 'runtime-only', path: 'private.patch', reason: 'Different runtime layout.' }, + 'other@2.0.0': { scope: 'runtime-only', path: 'private.patch', reason: 'Runtime-only dependency.' }, + }, {}) + expect(plan.patches).toHaveLength(2) + expect(readFileSync(join(project, plan.patches[0]!.path), 'utf8')).toContain('+private') + expect(readFileSync(join(source, 'shared.patch'), 'utf8')).toBe(patch) +}) + +it.each([ + { policy: {}, message: 'classify the workspace patch' }, + { policy: { 'fixture@1.0.0': shared, 'absent@1.0.0': shared }, message: 'stale workspace patch decision' }, + { policy: { 'fixture@1.0.0': { scope: 'runtime-only', path: '../outside.patch', reason: 'Invalid path.' } }, message: 'inside the repository' }, + { policy: { 'fixture@1.0.0': { scope: 'runtime-only', path: 'missing.patch', reason: 'Missing file.' } }, message: 'ENOENT' }, +] satisfies Array<{ policy: Record; message: string }>)('rejects invalid selection: $message', ({ policy, message }) => { + const { source, project } = fixture() + expect(() => { prepareRuntimePatches(project, source, policy, {}) }).toThrow(message) +}) + +it('rejects shared bytes that differ from the workspace lockfile', () => { + const { source, project } = fixture() + writeFileSync(join(source, 'shared.patch'), patch + '\n') + expect(() => { prepareRuntimePatches(project, source, { 'fixture@1.0.0': shared }, {}) }).toThrow('root lockfile') +}) + +it('refuses to write to the workspace or replace an unrelated existing override', () => { + const { source, project } = fixture() + expect(() => { prepareRuntimePatches(source, source, { 'fixture@1.0.0': shared }, {}) }).toThrow('source workspace') + expect(() => { prepareRuntimePatches(project, source, { 'fixture@1.0.0': shared }, { internal: '2.0.0' }) }).toThrow('conflicting') +}) + +it.each(['version', 'lock hash', 'file bytes', 'workspace-only', 'unused'] as const)('rejects invalid resolved installation: %s', (failure) => { + const { source, project } = fixture() + const plan = prepareRuntimePatches(project, source, { 'fixture@1.0.0': failure === 'workspace-only' ? excluded : shared }, {}) + writeFileSync(join(project, 'pnpm-lock.yaml'), yaml.dump({ + patchedDependencies: Object.fromEntries(plan.patches.map(entry => [entry.spec, failure === 'lock hash' ? 'wrong' : entry.hash])), + packages: failure === 'unused' ? {} : { [failure === 'version' ? 'fixture@1.0.1' : 'fixture@1.0.0']: {} }, + })) + if (failure === 'file bytes') writeFileSync(join(project, plan.patches[0]!.path), 'changed') + expect(() => { verifyRuntimePatches(project, plan) }).toThrow(/desktop patches:/u) +}) + +it.each([false, true])('pnpm applies runtime-only bytes and rejects an inapplicable patch (invalid=%s)', async (invalid) => { + const { source, project, root } = fixture() + const packageDir = join(root, 'package') + mkdirSync(packageDir) + writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0' })) + writeFileSync(join(packageDir, 'value.txt'), 'original\n') + const run = promisify(execFile) + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + /^(?:path|systemroot|windir|comspec|temp|tmp)$/iu.test(name) + ))) + await run('tar', ['-czf', 'fixture.tgz', '-C', root, 'package'], { cwd: project, env, timeout: 15_000, windowsHide: true }) + writeFileSync(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { fixture: 'file:./fixture.tgz' } })) + writeFileSync(join(project, 'npmrc'), '') + if (invalid) writeFileSync(join(source, 'private.patch'), patch.replace('-original', '-does-not-exist')) + const plan = prepareRuntimePatches(project, source, { + 'fixture@1.0.0': { scope: 'runtime-only', path: 'private.patch', reason: 'Offline install fixture.' }, + }, {}) + const [command, ...args] = pnpmCommand() + const install = run(command, [...args, 'install', '--offline', '--ignore-scripts', + `--config.userconfig=${join(project, 'npmrc')}`, `--config.store-dir=${join(project, 'store')}`, + '--config.manage-package-manager-versions=false'], { cwd: project, env: { ...env, CI: 'true' }, timeout: 30_000, windowsHide: true }) + if (invalid) { + const failure: unknown = await install.catch((error: unknown) => error) + if (!(failure instanceof Error) || !('stdout' in failure)) throw new Error('Expected a captured pnpm failure') + expect(failure).toHaveProperty('code', 1) + expect(failure.stdout).toMatch(/PATCH_FAILED/u) + } else { + await install + verifyRuntimePatches(project, plan) + expect(readFileSync(join(project, 'node_modules/fixture/value.txt'), 'utf8')).toBe('private\n') + } + expect(existsSync(join(source, 'node_modules'))).toBe(false) +}, 45_000) diff --git a/apps/desktop/tests/prepare-windows-ci.spec.ts b/apps/desktop/tests/prepare-windows-ci.spec.ts new file mode 100644 index 0000000000..67d62bbe19 --- /dev/null +++ b/apps/desktop/tests/prepare-windows-ci.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { windowsCiVersion } from '../scripts/prepare-windows-ci.mjs' + +const inputs = { PACKAGE_BUILD_VERSION: 'auto', GITHUB_RUN_NUMBER: '42', GITHUB_RUN_ATTEMPT: '2' } +const now = new Date('2026-10-09T01:02:03Z') + +describe('manual Windows CI version', () => { + it('uses the selected product prefix and UTC run identity', () => { + expect(windowsCiVersion(inputs, '0.2.1-alpha.1', now)).toBe('0.2.1-alpha.1.20261009.42.2') + expect(windowsCiVersion(inputs, '0.2.1', now)).toBe('0.2.1-test.20261009.42.2') + }) + it.each(['0.2.1-alpha.1', '0.2.1-alpha.1.20261009.7'])('accepts explicit version %s without run identity', (version) => { + expect(windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)).toBe(version) + }) + it.each(['', 'bad', '0.3.0'])('reports checkout-specific guidance for %s', (version) => { + expect(() => windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)) + .toThrow(/auto, 0\.2\.1-alpha\.1, or e\.g\. 0\.2\.1-alpha\.1\.20261009\.1/u) + }) + it.each([{ GITHUB_RUN_NUMBER: '0' }, { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' }])('rejects invalid run identity %j', (override) => { + expect(() => windowsCiVersion({ ...inputs, ...override }, '0.2.1', now)).toThrow(/positive integer/u) + }) +}) diff --git a/apps/desktop/tests/prepared-runtime-smoke.spec.ts b/apps/desktop/tests/prepared-runtime-smoke.spec.ts index 2a2644719e..c4c840c009 100644 --- a/apps/desktop/tests/prepared-runtime-smoke.spec.ts +++ b/apps/desktop/tests/prepared-runtime-smoke.spec.ts @@ -21,6 +21,27 @@ afterEach(() => { Object.defineProperty(process, 'arch', hostArch) for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) vi.clearAllMocks() + vi.restoreAllMocks() +}) + +it('retains payload progress and termination details when the child fails', async () => { + const root = mkdtempSync(join(tmpdir(), 'prepared-runtime-failure-')) + roots.push(root) + const fixture = runtimeFixture(root) + const target = { platform: 'win32' as const, arch: 'x64' } + writeDesktopRuntime(root, fixture.release, fixture.sharedPackages.map(entry => entry.name), target) + const descriptor = await verifyDesktopRuntime(root, fixture.release.version, target) + const failure = Object.assign(new Error('payload timed out'), { + stdout: 'payload output\n', stderr: 'runtime payload: pty\n', killed: true, signal: 'SIGTERM', + }) + payload.mockRejectedValueOnce(failure) + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + await expect(smokePreparedRuntime(root, join(root, 'electron'), join(root, 'runtime'), descriptor)).rejects.toBe(failure) + expect(stdout).toHaveBeenCalledWith('payload output\n') + expect(stderr).toHaveBeenCalledWith('runtime payload: pty\n') + expect(stderr).toHaveBeenCalledWith('runtime payload: killed=true, signal=SIGTERM\n') + expect(smokeDesktopRuntime).not.toHaveBeenCalled() }) it('smokes an x64 target verified on an arm64 build host without revalidating against the host', async () => { diff --git a/apps/desktop/tests/ptc-runtime.spec.ts b/apps/desktop/tests/ptc-runtime.spec.ts index 757bfe95b2..0766da0e67 100644 --- a/apps/desktop/tests/ptc-runtime.spec.ts +++ b/apps/desktop/tests/ptc-runtime.spec.ts @@ -30,7 +30,7 @@ it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace- await mkdir(cwd) vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit') - const runtime = await mountRuntime(ctx, { nodeExecutable: electron }, { mode, workspaceRoot: cwd }) + const { runtime } = await mountRuntime(ctx, { launch: { kind: 'node-script', executable: electron } }, { mode, workspaceRoot: cwd }) const result = await runtime.run(runtime.resolve({ // Bound startup failure before the outer test deadline, leaving time for managed cleanup. timeoutMs: 30_000, diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts index 76f5c8d95e..ed478b42f6 100644 --- a/apps/desktop/tests/runtime-file-policy.spec.ts +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -27,6 +27,8 @@ it('omits development artifacts while preserving executable modules, assets and 'node-pty/prebuilds/win32-x64/conpty.pdb', '@koromix/koffi-win32-x64/win32_x64/koffi.lib', '@mixmark-io/domino/test/entities.html', + '@mixmark-io/domino/.yarn/plugins/@yarnpkg/plugin-version.cjs', + '@mixmark-io/domino/.yarn/versions/release.yml', '@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data', '@deepseek-ai/libreoffice-kit-darwin-arm64/bin/libreoffice-kit', '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache', @@ -45,6 +47,8 @@ it('omits development artifacts while preserving executable modules, assets and 'node-pty/third_party/conpty/win10-x64/OpenConsole.exe', '@koromix/koffi-win32-x64/win32_x64/koffi.node', '@mixmark-io/domino/lib/HTMLParser.js', '@mixmark-io/domino/lib/EntityParser.js', + '@mixmark-io/domino/LICENSE', '@mixmark-io/domino/package.json', + 'example/.yarn/runtime.cjs', '@mixmark-io/domino/.yarn-data.json', '@img/sharp-win32-x64/lib/libvips-42.dll', '@deepseek-ai/libreoffice-kit-win32-x64/bin/libreoffice-kit.exe', '@deepseek-ai/libreoffice-kit-win32-x64/sources/core-source.json', @@ -73,6 +77,8 @@ it('omits development artifacts while preserving executable modules, assets and it('applies package-specific rules inside scoped and nested dependency containers', () => { expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows, 'win32-x64')).toBeDefined() + expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/.yarn/plugin.cjs', windows, 'win32-x64')).toBeDefined() + expect(desktopRuntimeFileExclusion('@mixmark-io/domino/.yarn', windows, 'win32-x64')).toBeDefined() expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows, 'win32-x64')).toBeDefined() expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows, 'win32-x64')).toBeUndefined() expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows, 'win32-x64')).toBeUndefined() @@ -85,6 +91,62 @@ it('retains native prebuilds for the selected macOS architecture', () => { expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac, 'darwin-arm64')).toBeDefined() }) +it.each([ + ['darwin', 'arm64'], ['darwin', 'x64'], + ['linux', 'arm64'], ['linux', 'x64'], + ['win32', 'arm64'], ['win32', 'x64'], +] as const)('copies ConPTY sources only for Windows targets (%s/%s)', (platform, arch) => { + const root = mkdtempSync(join(tmpdir(), 'desktop-conpty-policy-')) + try { + const source = join(root, 'source') + const output = join(root, 'output') + const prefixes = ['', '@example/outer/node_modules/'] + const conpty = ['arm64', 'x64'].flatMap(cpu => ['conpty.dll', 'OpenConsole.exe'].map( + file => `node-pty/third_party/conpty/1.25.260303002/win10-${cpu}/${file}`, + )) + const native = platform === 'win32' + ? ['conpty.node', 'conpty_console_list.node', 'conpty/conpty.dll', 'conpty/OpenConsole.exe'] + : ['pty.node', 'spawn-helper'] + const retained = [ + ...native.map(file => `node-pty/prebuilds/${platform}-${arch}/${file}`), + ...native.map(file => `node-pty/build/Release/${file}`), + 'node-pty/lib/index.js', 'node-pty/LICENSE', + 'node-pty/third_party/conpty-notes/README.md', 'node-pty/third_party/conpty.dll', + 'other/third_party/conpty/conpty.dll', + ] + for (const prefix of prefixes) { + for (const path of [...conpty, ...retained]) { + mkdirSync(join(source, prefix, path, '..'), { recursive: true }) + writeFileSync(join(source, prefix, path), `payload:${prefix}${path}`) + } + } + cpSync(source, output, { + recursive: true, dereference: true, + filter: (path) => { + const entry = relative(source, path) + return desktopRuntimeFileExclusion( + platform === 'win32' ? entry.replaceAll('/', '\\') : entry, + { platform, arch }, `${platform}-${arch}`, + ) === undefined + }, + }) + for (const prefix of prefixes) { + expect(existsSync(join(output, prefix, 'node-pty/third_party/conpty'))).toBe(platform === 'win32') + for (const path of conpty) { + expect(existsSync(join(output, prefix, path)), `${prefix}${path}`).toBe(platform === 'win32') + } + for (const path of [...retained, ...(platform === 'win32' ? conpty : [])]) { + expect(readFileSync(join(output, prefix, path), 'utf8')).toBe(`payload:${prefix}${path}`) + } + for (const path of conpty) { + expect(readFileSync(join(source, prefix, path), 'utf8')).toBe(`payload:${prefix}${path}`) + } + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + it.each([ ['linux', 'x64', 'linux-x64'], ['linux', 'arm64', 'wasm'], diff --git a/apps/desktop/tests/runtime-preparation.spec.ts b/apps/desktop/tests/runtime-preparation.spec.ts new file mode 100644 index 0000000000..d16dd592c2 --- /dev/null +++ b/apps/desktop/tests/runtime-preparation.spec.ts @@ -0,0 +1,120 @@ +/** Desktop resource preparation publishes versions only for complete resources. */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + root: '', + target: 'mac-arm64' as 'mac-arm64' | 'mac-x64' | 'win-x64', + pnpm: '11.7.0' as string | undefined, + failure: undefined as 'electron' | 'primary' | 'cli' | undefined, + preparePrimary: vi.fn(), + commandLink: vi.fn(), +})) +vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({ + ...await importOriginal(), + resolveDesktopBuildTarget: () => state.target, + resolveDesktopTargetBuildPaths: () => ({ + runtime: join(state.root, 'runtime'), electron: join(state.root, 'electron'), downloads: join(state.root, 'downloads'), + }), +})) +vi.mock('@electron/get', () => ({ downloadArtifact: async () => 'electron.zip' })) +vi.mock('extract-zip', () => ({ default: async () => { + if (state.failure === 'electron') throw new Error('Electron extraction failed') +} })) +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + execFileSync: (file: string) => file === '/usr/libexec/PlistBuddy' ? '12.0\n' : '24.18.1\n', +})) +vi.mock('../scripts/prepare-command-link.ts', () => ({ prepareCommandLink: state.commandLink })) +// The compiled command manager is a build input; all source resource copies use real files. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, cpSync: (...args: Parameters) => { + if (String(args[0]).endsWith(join('lib', 'command-manager-entry.js'))) { + if (state.failure === 'cli') throw new Error('command manager copy failed') + actual.writeFileSync(args[1], 'command manager fixture') + } else actual.cpSync(...args) + } } +}) +vi.mock('../scripts/prepare-primary-runtime.ts', () => ({ + preparePrimaryRuntime: state.preparePrimary, +})) + +const originalArgv = process.argv +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + state.root = mkdtempSync(join(tmpdir(), 'desktop-runtime-preparation-')) + state.target = 'mac-arm64' + state.pnpm = '11.7.0' + state.failure = undefined + process.argv = [process.execPath, 'prepare-runtime.ts'] + vi.stubEnv('DSH_DESKTOP_PACKAGING_RUN_DIR', undefined) + state.preparePrimary.mockImplementation(async () => { + if (state.failure === 'primary') throw new Error('primary preparation failed') + const root = join(state.root, 'runtime', 'primary-runtime') + const pnpm = join(root, 'dependencies', 'pnpm', 'bin') + mkdirSync(pnpm, { recursive: true }) + writeFileSync(join(pnpm, 'pnpm.mjs'), '') + writeFileSync(join(root, 'runtime.json'), JSON.stringify({ + desktopVersion: '1.0.0', platform: state.target === 'win-x64' ? 'win32' : 'darwin', + arch: state.target === 'mac-arm64' ? 'arm64' : 'x64', + python: '3.12.14', node: '24.17.0', pnpm: state.pnpm, pythonPackages: { numpy: '2.3.5' }, + })) + }) +}) + +afterEach(async () => { + process.argv = originalArgv + vi.unstubAllEnvs() + await rm(state.root, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) +}) + +it.each(['mac-arm64', 'mac-x64', 'win-x64'] as const)('prepares %s with one pnpm distribution and its CLI resources', async (target) => { + state.target = target + const windows = target === 'win-x64' + if (windows) process.argv.push('--defer-primary-runtime-smoke') + const runtime = join(state.root, 'runtime') + mkdirSync(join(runtime, 'pnpm'), { recursive: true }) + await import('../scripts/prepare-runtime.ts') + expect(existsSync(join(runtime, 'pnpm'))).toBe(false) + expect(existsSync(join(runtime, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'))).toBe(true) + expect(existsSync(join(runtime, 'bin', 'node'))).toBe(true) + expect(existsSync(join(runtime, 'cli', 'bin', windows ? 'dsh.cmd' : 'dsh'))).toBe(true) + expect(readFileSync(join(runtime, 'cli', 'command-manager.js'), 'utf8')).toBe('command manager fixture') + expect(existsSync(join(runtime, 'cli', 'command-path.ps1'))).toBe(true) + expect(JSON.parse(readFileSync(join(runtime, 'versions.json'), 'utf8'))).toEqual({ + schemaVersion: 1, node: '24.18.1', pnpm: '11.7.0', + }) + expect(state.preparePrimary).toHaveBeenCalledWith({ deferSmoke: windows }) + if (windows) expect(state.commandLink).not.toHaveBeenCalled() + else expect(state.commandLink).toHaveBeenCalledWith(join(runtime, 'cli'), target === 'mac-arm64' ? 'arm64' : 'x64', '12.0') +}) + +it.each([ + ['electron', 'Electron extraction failed'], + ['primary', 'primary preparation failed'], + ['cli', 'command manager copy failed'], +] as const)('does not publish versions when %s preparation fails', async (failure, error) => { + state.failure = failure + const runtime = join(state.root, 'runtime') + mkdirSync(runtime) + writeFileSync(join(runtime, 'versions.json'), 'stale versions') + await expect(import('../scripts/prepare-runtime.ts')).rejects.toThrow(error) + expect(existsSync(join(runtime, 'versions.json'))).toBe(false) +}) + +it.each([ + ['not-a-version', 'primary runtime: invalid metadata'], + [undefined, 'primary-runtime manifest has no pnpm version'], +])('rejects pnpm metadata %s even when Windows smoke is deferred', async (pnpm, error) => { + state.target = 'win-x64' + process.argv.push('--defer-primary-runtime-smoke') + state.pnpm = pnpm + await expect(import('../scripts/prepare-runtime.ts')).rejects.toThrow(error) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) +}) diff --git a/apps/desktop/tests/shell-file-selection.spec.ts b/apps/desktop/tests/shell-file-selection.spec.ts new file mode 100644 index 0000000000..115d75392a --- /dev/null +++ b/apps/desktop/tests/shell-file-selection.spec.ts @@ -0,0 +1,110 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' +import { Packager, type Configuration, type PlatformSpecificBuildOptions } from 'app-builder-lib' +import { readAsar } from 'app-builder-lib/out/asar/asar.js' +import type { FileMatcher } from 'app-builder-lib/out/fileMatcher.js' +import { MacPackager } from 'app-builder-lib/out/macPackager.js' +import { PM } from 'app-builder-lib/out/node-module-collector/index.js' +import type { PlatformPackager } from 'app-builder-lib/out/platformPackager.js' +import type { ResolvedFileSet } from 'app-builder-lib/out/util/appFileCopier.js' +import { WinPackager } from 'app-builder-lib/out/winPackager.js' +import { expect, it, onTestFinished, vi } from 'vitest' +import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' + +const require = createRequire(import.meta.url) +// The locked builder exports these packaging operations without declarations. +const { getNodeModuleFileMatcher } = require('app-builder-lib/out/fileMatcher.js') as { + getNodeModuleFileMatcher: (app: string, destination: string, expand: (value: string) => string, + options: PlatformSpecificBuildOptions, packager: Packager) => FileMatcher +} +const { computeNodeModuleFileSets } = require('app-builder-lib/out/util/appFileCopier.js') as { + computeNodeModuleFileSets: (packager: PlatformPackager, matcher: FileMatcher) => Promise +} +const { AsarPackager } = require('app-builder-lib/out/asar/asarUtil.js') as { + AsarPackager: new (packager: object, configuration: object) => { pack(files: ResolvedFileSet[]): Promise } +} + +it.each([ + ['darwin', 'arm64'], ['darwin', 'x64'], ['win32', 'x64'], +] as const)('omits shell build files from %s/%s ASAR while retaining runtime assets', async (platform, arch) => { + const root = await mkdtemp(join(tmpdir(), 'desktop-shell-files-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const appDir = join(root, 'app') + const resources = join(root, 'resources') + const destination = join(resources, 'app') + await mkdir(resources, { recursive: true }) + const manifest = { name: 'fixture', version: '1.0.0', dependencies: { shell: '1.0.0', '@fixture/shared': '2.0.0' } } + const packages = [ + ['node_modules/shell', { name: 'shell', version: '1.0.0', dependencies: { '@fixture/shared': '1.0.0' } }], + ['node_modules/@fixture/shared', { name: '@fixture/shared', version: '2.0.0' }], + ['node_modules/shell/node_modules/@fixture/shared', { name: '@fixture/shared', version: '1.0.0' }], + ] as const + const omitted = [ + 'index.js.map', 'lib/index.cjs.map', 'lib/index.mjs.map', 'lib/index.ts.map', + 'lib/index.cts.map', 'lib/index.mts.map', 'lib/style.css.map', 'tsconfig.tsbuildinfo', 'lib/types/tsconfig.tsbuildinfo', + ] + const retained = [ + 'index.js', 'lib/index.cjs', 'lib/index.mjs', 'lib/index.ts', 'lib/index.cts', 'lib/index.mts', + 'lib/style.css', 'lib/types/runtime.js', 'data.map', 'lib/data.map', 'lib/addon.node', 'lib/engine.wasm', 'LICENSE', 'NOTICE', + ] + const originals = new Map() + for (const [directory, metadata] of packages) { + for (const name of [...retained, ...omitted, 'package.json']) { + const file = join(appDir, directory, name) + const contents = Buffer.from(name === 'package.json' ? JSON.stringify(metadata) : `${directory}/${name}\n`) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, contents) + originals.set(file, contents) + } + } + await writeFile(join(appDir, 'package.json'), JSON.stringify(manifest)) + const desktopConfig = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: 'com.example.shell-files', + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), + DSH_DESKTOP_UNSIGNED: platform === 'win32' ? '1' : '0', + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', + APPLE_KEYCHAIN_PROFILE: 'shell-files-test', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', + }, platform, arch) + const config: Configuration = { + files: desktopConfig.files.map(file => typeof file === 'string' ? file : { ...file, filter: [...file.filter] }), + } + const info = new Packager({ projectDir: appDir }) + onTestFinished(() => info.tempDirManager.cleanup()) + Object.defineProperties(info, { + appDir: { value: appDir }, config: { value: config }, + metadata: { value: manifest }, originalMetadata: { value: manifest }, + appInfo: { value: { type: 'commonjs' } }, + framework: { value: { name: 'electron' } }, + }) + // Traverse the installed fixture without invoking a host package manager. + vi.spyOn(info, 'getPackageManager').mockResolvedValue(PM.TRAVERSAL) + vi.spyOn(info, 'getWorkspaceRoot').mockResolvedValue(appDir) + const packager = platform === 'darwin' ? new MacPackager(info) : new WinPackager(info) + const matcher = getNodeModuleFileMatcher(appDir, destination, value => value, packager.platformSpecificBuildOptions, info) + const fileSets = await computeNodeModuleFileSets(packager, matcher) + expect(fileSets.map(set => relative(destination, set.destination).replaceAll('\\', '/')).sort()) + .toEqual(packages.map(([directory]) => directory).sort()) + const consoleLog = console.log + try { + await new AsarPackager(packager, { + defaultDestination: destination, resourcePath: resources, options: { smartUnpack: false }, + }).pack(fileSets) + } finally { + // Builder temporarily intercepts console.log while writing ASAR. + console.log = consoleLog + } + const archive = await readAsar(join(resources, 'app.asar')) + for (const [directory] of packages) { + for (const name of [...retained, 'package.json']) { + expect(await archive.readFile(join(directory, name))).toEqual(originals.get(join(appDir, directory, name))) + } + for (const name of omitted) expect(archive.getNode(join(directory, name))).toBeUndefined() + } + for (const [file, contents] of originals) expect(await readFile(file)).toEqual(contents) +}) diff --git a/apps/desktop/tests/windows-package-preset.spec.ts b/apps/desktop/tests/windows-package-preset.spec.ts new file mode 100644 index 0000000000..c64372309b --- /dev/null +++ b/apps/desktop/tests/windows-package-preset.spec.ts @@ -0,0 +1,93 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' + +async function withPreset(run: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), 'dsh-windows-preset-')) + try { + await mkdir(join(root, 'presets')) + await mkdir(join(root, 'secrets')) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_APP_ID=com.example.desktop\n') + await run(root) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +it('loads unsigned public settings without requiring the signing secret file or personal dotenv', async () => { + await withPreset(async (root) => { + const environment = loadDesktopPackageEnvironment('win32', { + DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'ambient-secret', + }, join(root, 'missing-checkout'), { unsigned: true }) + expect(environment.DSH_DESKTOP_APP_ID).toBe('com.example.desktop') + expect(environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + const prepared = loadDesktopPackageEnvironment('win32', { + DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', + }, root, { prepareOnly: true }) + expect(prepared.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + expect(prepared.DSH_PACKAGING_PRESET).toBe('test') + expect(() => loadDesktopPackageEnvironment('win32', { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' })) + .toThrow(/cannot read/u) + }) +}) + +it('resolves signing file paths relative to the secret file and rejects public overrides there', async () => { + await withPreset(async (root) => { + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } + const file = join(root, 'secrets/windows-signing.env') + await writeFile(file, 'DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/ev.cer\nDSH_DESKTOP_WINDOWS_TOKEN_PIN="private-value"\n') + const result = loadDesktopPackageEnvironment('win32', env) + expect(result.DSH_DESKTOP_WINDOWS_CER_FILE).toBe(join(root, 'certificates/ev.cer')) + expect(result.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBe('private-value') + await writeFile(file, 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') + expect(() => loadDesktopPackageEnvironment('win32', env)).toThrow(/unsupported setting/u) + }) +}) + +it('preserves the selected preset when Windows environment enumeration uses lowercase keys', async () => { + await withPreset(async (root) => { + const values: NodeJS.ProcessEnv = { dsh_packaging_config_dir: root, dsh_packaging_preset: 'test' } + const environment = new Proxy(values, { + get: (target, name) => typeof name === 'string' ? target[name.toLowerCase()] : undefined, + }) + const selected = loadDesktopPackageEnvironment('win32', environment, root, { unsigned: true }) + expect(selected.DSH_PACKAGING_PRESET).toBe('test') + }) +}) + +it('rejects missing presets, traversal, deployment mismatches and secrets in public configuration', async () => { + await withPreset(async (root) => { + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } + const load = (override = {}) => loadDesktopPackageEnvironment('win32', { ...env, ...override }, root, { unsigned: true }) + expect(() => load({ DSH_PACKAGING_CONFIG_DIR: 'relative' })).toThrow(/absolute/u) + expect(() => load({ DSH_PACKAGING_CONFIG_DIR: undefined })).toThrow(/absolute/u) + expect(() => loadDesktopPackageEnvironment('darwin', env)).toThrow(/Windows only/u) + expect(() => load({ DSH_PACKAGING_PRESET: '../secrets' })).toThrow(/preset must/u) + expect(() => load({ DSH_PACKAGING_PRESET: 'production' })).toThrow(/cannot read/u) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') + expect(() => load()).toThrow(/must match/u) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_WINDOWS_TOKEN_PIN=private-value\n') + expect(() => load()).toThrow(/unsupported setting DSH_DESKTOP_WINDOWS_TOKEN_PIN/u) + expect(() => load()).not.toThrow(/private-value/u) + }) +}) + +it('cache maintenance reaches cache path validation without a signing file', async () => { + await withPreset(async (root) => { + await writeFile(join(root, 'presets/test.env'), + 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=relative-cache\n') + const result = spawnSync(process.execPath, + [join(import.meta.dirname, '../scripts/manage-windows-signature-cache.mjs'), '--usage'], { + env: { ...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }, + encoding: 'utf8', timeout: 10_000, windowsHide: true, + }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status).toBe(1) + expect(result.stderr).toContain('absolute local') + expect(result.stderr).not.toContain('cannot read') + }) +}) diff --git a/apps/desktop/tests/windows-pty-exit.spec.ts b/apps/desktop/tests/windows-pty-exit.spec.ts new file mode 100644 index 0000000000..d6fa3d381e --- /dev/null +++ b/apps/desktop/tests/windows-pty-exit.spec.ts @@ -0,0 +1,15 @@ +import { execFile } from 'node:child_process' +import { resolve } from 'node:path' +import { promisify } from 'node:util' +import { expect, it } from 'vitest' + +it.skipIf(process.platform !== 'win32').each(['natural', 'kill'])('releases the terminal worker after %s exit', async (mode) => { + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + /^(?:path|systemroot|windir|comspec|temp|tmp)$/iu.test(name) + ))) + const result = await promisify(execFile)(process.execPath, [resolve(import.meta.dirname, 'fixtures/windows-pty-exit.cjs'), mode], { + env, timeout: 15_000, windowsHide: true, + }) + expect(result.stderr).toBe('') + expect(result.stdout.trim()).toBe('pty-cleanup-complete') +}, 20_000) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml new file mode 100644 index 0000000000..c063a69c88 --- /dev/null +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/windows-signing/README.md +/: + en: 5b2af461afadb867 + zh: c7558c795a752944 +/windows-signing-and-runner-operations: + en: 65f1ac70a69830fd + zh: cd5825b41e9e30f4 +/windows-signing-and-runner-operations/summary: + en: 3eec637b807a2d4c + zh: e042992b9ea83345 +/windows-signing-and-runner-operations/table-of-contents: + en: 3a812cc5ad554fd3 + zh: 0401b5764c155c0b +/windows-signing-and-runner-operations/trust-and-isolation-limits: + en: 0c261f3948178d55 + zh: 1086cd882468ad8f +/windows-signing-and-runner-operations/machine-setup-and-runner-startup: + en: ba3de07cc9553056 + zh: 887e652c40d57091 +/windows-signing-and-runner-operations/signing-configuration: + en: 4b5548818375eb9a + zh: 8c975adbb1dca9c8 +/windows-signing-and-runner-operations/signing-and-timestamp-completion: + en: e42565e00283e3f6 + zh: 584a33f07b50fd5b +/windows-signing-and-runner-operations/failure-records-and-operator-recovery: + en: 5401b605a2f3a44e + zh: fc1f6ee667bcd20d +/windows-signing-and-runner-operations/signature-cache-and-concurrent-stages: + en: 24032af67e83a003 + zh: 76894f57a9dfed52 +/windows-signing-and-runner-operations/verification-limits: + en: 94a471410c84a1c7 + zh: 115228e4ca36581d +/windows-signing-and-runner-operations/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md new file mode 100644 index 0000000000..eb3fd4a76d --- /dev/null +++ b/apps/desktop/windows-signing/README.md @@ -0,0 +1,93 @@ +--- +description: "Configure and operate the Windows packaging runner, signing credentials, EV token and signature cache. Read the accepted isolation risks before running branch code or accessing the token. Hardware failures require operator inspection; packaging success does not establish installation or production readiness." +--- + +# Windows signing and runner operations + +English | [中文](README.zh.md) + +## Summary + +Configure and operate the Windows packaging runner, signing credentials, EV token and signature cache. Read the accepted isolation risks before running branch code or accessing the token. Hardware failures require operator inspection; packaging success does not establish installation or production readiness. + +## Table of Contents + +- [Trust and isolation limits](#trust-and-isolation) +- [Machine setup and runner startup](#machine-setup) +- [Signing configuration](#signing-configuration) +- [Signing and timestamp completion](#windows-ev-signing) +- [Failure records and operator recovery](#failure-and-recovery) +- [Signature cache and concurrent stages](#signature-cache) +- [Verification limits](#verification) + + + +## Trust and isolation limits + +The shared packaging runner assumes trusted internal-team code and operators. Under this assumption, credential and device isolation are deferred and the residual risk is accepted (tracked in #5973); signed/master-only access is not enforced. Unsigned mode skips signing-file loading but cannot prevent branch code running as the same Windows account from reading those files or accessing the token. SafeNet Single Logon may reuse authentication. Direct device calls can bypass the signing scripts and exhaust PIN retries; the persistent failure interlock only protects calls through those scripts. Cross-account token isolation and remaining retry counts are unverified. Reassess runner/account/device isolation before expanding access, executing untrusted contributions, changing runner sharing, or after suspicious activity. + + + +## Machine setup and runner startup + +Use a runner-owned checkout and keep configuration, certificates and caches outside it. Registration and tool installation remain operator tasks. Packaging inputs and artifact retrieval are described in the [packaging guide](../packaging/README.md#windows-actions). + +Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` to prepare private `packaging/{presets,secrets,certificates}` and `cache/windows-signatures` directories and copy templates without overwriting existing files. Add `-CheckOnly` to inspect without writes. Fill the [preset templates](../config/windows-packaging/test.env.example) and [signing template](../config/windows-packaging/windows-signing.env.example) on the machine. Run `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly` from the same scripts directory to inspect PowerShell 7.6.6 under the runner's `tools` directory, Node, Python, Git, tar and Visual C++ tools; packaging `--check` performs the remaining toolchain validation. Omit `-CheckOnly` to start the existing runner with an explicit config root and scrubbed inherited environment. Registration and tool installation are separate; these scripts never create tokens, alter system security settings or clear signing failure records. Use the signing cache maintenance command below to migrate existing entries before changing the preset's cache path. + + + +## Signing configuration + +The [machine preset configuration](../packaging/README.md#configuration-and-local-builds) selects public settings and the signing file. Without a preset, use the target dotenv file. + +Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certificate), `DSH_DESKTOP_WINDOWS_SIGNTOOL` (SafeNet-compatible SignTool), `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` (matching private-key container), and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` (Token Password). The private key stays on the USB token; keep the certificate and local credential file out of Git. + +The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` or machine-owned `secrets/windows-signing.env` stores the PIN in plaintext and needs restricted file access. CI reads the machine-owned file outside the checkout and retains it across jobs. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. + +Signed Windows configuration derives the updater's `publisherName` from the same public certificate's `CN`, `O`, and `C` attributes. Each must be present, nonempty, and single-valued. These identity attributes allow certificate renewal without pinning a leaf thumbprint. The installed application's `app-update.yml` carries the expected publisher; the downloaded feed does not choose it. Unsigned test builds omit updater configuration. See the [signature qualification record](../tests/README.md) for real-file verification and its limits. + + + +## Signing and timestamp completion + +Follow the [version and build instructions](../packaging/README.md#release-versions) before starting a signed build. Configuration checks do not establish token authentication. + +Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. + +Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. + +Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md). + + + +## Failure records and operator recovery + +Treat `SignTool Error: No private key is available.` as a signing failure: stop all signing attempts and have the operator inspect the signing configuration and token state before continuing. The device-specific PIN retry limit and remaining attempts are unverified; do not probe them with authentication attempts. Do not retry packaging or signing probes after this error. The signer serializes token operations and rejects all queued tasks after the first failure. + +Windows package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `.desktop-build/packaging-runs/`. Each run retains `run.json`, timestamped `events.jsonl`, redacted `stdout.log` and `stderr.log`, and `result.json`. A signing failure also writes `fatal.json` and notifies the parent through stderr; the supervisor immediately requests termination of its stage process tree and waits for exit. Failed stages cannot start subsequent stages or create a release completion record. Journal failures also stop the run. Termination errors remain failures and require operator inspection; a missing final record means completion was not established. + +Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing followed by verification of the configured primary signature releases that file; timestamps are completed afterward without hardware access. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock. + + + +## Signature cache and concurrent stages + +Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits. + +Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. + +Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required. + +Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. + +Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. + + + +## Verification limits + +Real hardware and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy. + +## Dev Note + +None. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md new file mode 100644 index 0000000000..70718a8bf7 --- /dev/null +++ b/apps/desktop/windows-signing/README.zh.md @@ -0,0 +1,93 @@ +--- +description: "配置和维护 Windows 打包 runner、签名凭据、EV Token 与签名缓存。运行分支代码或访问 Token 前,先阅读已接受的隔离风险。硬件失败需要操作者检查;打包成功不代表安装或生产发布已通过验收。" +--- + +# Windows 签名与 runner 运维 + +[English](README.md) | 中文 + +## 概述 + +配置和维护 Windows 打包 runner、签名凭据、EV Token 与签名缓存。运行分支代码或访问 Token 前,先阅读已接受的隔离风险。硬件失败需要操作者检查;打包成功不代表安装或生产发布已通过验收。 + +## 目录 + +- [信任前提与隔离缺陷](#trust-and-isolation) +- [机器配置与 runner 启动](#machine-setup) +- [签名配置](#signing-configuration) +- [签名与时间戳处理](#windows-ev-signing) +- [失败记录与人工恢复](#failure-and-recovery) +- [签名缓存与并发阶段](#signature-cache) +- [验证范围](#verification) + + + +## 信任前提与隔离缺陷 + +共享打包 runner 以内部团队代码和操作人员可信为前提。在此前提下,凭据和设备隔离暂缓实施,剩余风险按已知风险接受(由 #5973 跟踪);尚未强制限制仅 signed/master 可访问。未签名模式跳过签名文件加载,但无法阻止同一 Windows 账户下的分支代码读取这些文件或访问 Token。SafeNet Single Logon 可能复用认证状态。直接调用设备可以绕过签名脚本并耗尽 PIN 重试次数;持久失败 interlock 只保护经过这些脚本的调用。跨账户 Token 隔离和剩余重试次数尚未验证。扩大访问范围、执行不可信贡献、改变 runner 共享方式前,或出现可疑活动后,重新评估 runner、账户和设备隔离。 + + + +## 机器配置与 runner 启动 + +使用 runner 专用 checkout,并将配置、证书和缓存放在其外部。注册和工具安装由操作者负责。打包输入与产物获取见[打包指南](../packaging/README.zh.md#windows-actions)。 + +运行 `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` 创建私有 `packaging/{presets,secrets,certificates}` 和 `cache/windows-signatures` 目录并复制模板,不覆盖现有文件。添加 `-CheckOnly` 只检查不写入。在机器上填写 [preset 模板](../config/windows-packaging/test.env.example)和[签名模板](../config/windows-packaging/windows-signing.env.example)。从同一 scripts 目录运行 `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly`,检查 runner 的 `tools` 目录下 PowerShell 7.6.6、Node、Python、Git、tar 和 Visual C++ 工具;打包 `--check` 完成其余工具链检查。省略 `-CheckOnly`,以显式配置目录和清理后的继承环境启动现有 runner。注册和工具安装单独进行;这些脚本不创建 token、不改系统安全设置、不清除签名失败记录。修改 preset 缓存路径前,使用下文签名缓存维护命令迁移已有条目。 + + + +## 签名配置 + +通过[机器 preset 配置](../packaging/README.zh.md#configuration-and-local-builds)选择公开设置与签名文件;不选择 preset 时使用目标 dotenv 文件。 + +在 `.env.windows` 中填写 `DSH_DESKTOP_WINDOWS_CER_FILE`(公开 EV 叶证书)、`DSH_DESKTOP_WINDOWS_SIGNTOOL`(SafeNet 兼容的 SignTool)、`DSH_DESKTOP_WINDOWS_KEY_CONTAINER`(匹配的私钥容器)和 `DSH_DESKTOP_WINDOWS_TOKEN_PIN`(Token Password)。私钥仍保留在 USB Token;不要把证书或本地凭据文件提交到 Git。 + +PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 或机器管理的 `secrets/windows-signing.env` 明文保存 PIN,应限制文件访问权限。CI 读取 checkout 外的机器签名文件,并在任务结束后保留。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 + +签名 Windows 配置从同一份公开证书的 `CN`、`O` 和 `C` 属性生成 updater 的 `publisherName`。每个属性都必须存在、非空且只有一个值。这些身份属性允许证书续期,无需固定叶证书指纹。已安装应用的 `app-update.yml` 保存预期发布者,下载的清单不能选择该身份。未签名测试构建省略 updater 配置。真实文件验证及其限制见[签名验收记录](../tests/README.zh.md)。 + + + +## 签名与时间戳处理 + +启动签名构建前,先按[版本与打包说明](../packaging/README.zh.md#release-versions)确认输入。配置检查不代表 Token 认证成功。 + +Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 + +打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 + +时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。 + + + +## 失败记录与人工恢复 + +将 `SignTool Error: No private key is available.` 视为签名失败:立即停止所有签名尝试,由操作者检查签名配置和 Token 状态后再继续。设备具体的 PIN 重试上限和剩余次数尚未验证,不得通过认证尝试探测。出现此错误后不要重试打包或签名探针。签名器串行执行 Token 操作,并在首次失败后拒绝所有排队任务。 + +Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/packaging-runs/` 下的唯一目录。每次运行保留 `run.json`、带时间戳的 `events.jsonl`、脱敏后的 `stdout.log` 和 `stderr.log`,以及 `result.json`。签名失败还会写入 `fatal.json` 并通过 stderr 通知父进程;监督程序立即请求终止当前阶段的进程树并等待退出。失败阶段不能启动后续阶段或生成发布完成记录。日志写入失败也会停止运行。终止错误仍按失败处理,需要操作者检查;缺少最终记录表示尚未确认完成。 + +硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功且配置证书的主签名通过验证后才释放该文件;随后完成时间戳,不再访问硬件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。 + + + +## 签名缓存与并发阶段 + +运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。 + +预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 + +缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 + +每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 + +使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 + + + +## 验证范围 + +真实硬件与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。 + +## 开发备注 + +无。 diff --git a/apps/web/katex-fonts.ts b/apps/web/katex-fonts.ts new file mode 100644 index 0000000000..702437384b --- /dev/null +++ b/apps/web/katex-fonts.ts @@ -0,0 +1,33 @@ +/** WOFF2-only KaTeX font sources for the Web and Desktop browser bundle. */ + +import { transform } from 'lightningcss' +import type { Plugin } from 'vite' + +/** + * Select WOFF2 before Vite collects KaTeX font assets, preserving every face and layout rule. + * A face without WOFF2 rejects the build. Other packages' stylesheets are unchanged. + * @returns Vite stylesheet transform for the installed KaTeX distribution. + */ +export function katexWoff2Fonts(): Plugin { + return { + name: 'dsh-katex-woff2-fonts', + enforce: 'pre', + transform(code, id) { + if (!/\/node_modules\/katex\/dist\/katex(?:\.min)?\.css(?:\?|$)/u.test(id.replaceAll('\\', '/'))) return + const result = transform({ + filename: id, code: Buffer.from(code), sourceMap: true, + visitor: { Rule: { 'font-face'(rule) { + const descriptors = rule.value.properties.filter(property => property.type === 'source') + if (descriptors.length === 0) throw new Error('KaTeX font face requires a WOFF2 source') + for (const property of descriptors) { + const sources = property.value.filter(source => source.type === 'url' && source.value.format?.type === 'woff2') + if (sources.length === 0) throw new Error('KaTeX font face requires a WOFF2 source') + property.value = sources + } + return rule + } } }, + }) + return { code: result.code.toString(), map: result.map?.toString() } + }, + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 6dfc75207f..1684c9a9d8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -46,10 +46,9 @@ "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:*", "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-session-projection-cache": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@types/node": "^22.20.1", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@types/ws": "8.18.1", "@vitejs/plugin-react": "^4.7.0", "http-server": "^14.1.1", diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index a47cad63f6..f97978d346 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -64,7 +64,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { const currentMenu = page.getByRole('menu') await currentMenu.waitFor({ timeout: 10_000 }) expect(await currentMenu.getByRole('menuitem').allTextContents()) - .toEqual(['仅可查看', '工作区内修改', '完全权限', 'Auto reviewEXP']) + .toEqual(['仅可查看', '工作区内修改', '完全权限', '自动审查EXP']) await captureAutoReviewState(page, 'experimental-current-session-picker') const currentSnapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) await compareOrRefreshGolden(CURRENT_SESSION_PICKER_EXPECTED, currentSnapshot, MODE) @@ -77,10 +77,10 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { expect(currentMenuBox!.width).toBeGreaterThan(currentTriggerBox!.width) expect(Math.abs(currentTriggerBox!.y - currentMenuBox!.y - currentMenuBox!.height - 4)).toBeLessThan(1) - await currentMenu.getByRole('menuitem', { name: 'Auto review EXP' }).click() - const autoDialog = page.getByRole('dialog', { name: '确认启用 Auto review(实验)?' }) + await currentMenu.getByRole('menuitem', { name: '自动审查 EXP' }).click() + const autoDialog = page.getByRole('dialog', { name: '确认启用自动审查(实验)?' }) await autoDialog.waitFor({ timeout: 10_000 }) - const autoEnable = autoDialog.getByRole('button', { name: '启用 Auto review' }) + const autoEnable = autoDialog.getByRole('button', { name: '启用自动审查' }) expect(await autoEnable.isDisabled()).toBe(true) expect(await autoDialog.getByText(/不使用沙箱/).count()).toBe(1) expect(await autoDialog.getByText(/误放行或误拒绝/).count()).toBe(1) @@ -105,7 +105,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await slashPicker.waitFor({ timeout: 10_000 }) const slashRows = slashPicker.getByRole('option') expect(await slashRows.count()).toBe(4) - expect(await slashPicker.getByRole('option', { name: 'Auto review EXP' }).count()).toBe(1) + expect(await slashPicker.getByRole('option', { name: '自动审查 EXP' }).count()).toBe(1) await captureAutoReviewState(page, 'experimental-slash-picker') const slashSnapshot = await captureStableAria(page, '[aria-label="/permission 选项"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SLASH_PICKER_EXPECTED, slashSnapshot, MODE) @@ -119,15 +119,15 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { expect(slashPickerBox!.x).toBeCloseTo(composerBox!.x) expect(Math.abs(composerBox!.y - slashPickerBox!.y - slashPickerBox!.height - 4)).toBeLessThan(1) - await slashPicker.getByRole('option', { name: 'Auto review EXP' }).click() - const slashDialog = page.getByRole('dialog', { name: '确认启用 Auto review(实验)?' }) + await slashPicker.getByRole('option', { name: '自动审查 EXP' }).click() + const slashDialog = page.getByRole('dialog', { name: '确认启用自动审查(实验)?' }) await slashDialog.waitFor({ timeout: 10_000 }) expect(await slashDialog.count()).toBe(1) expect(await slashPicker.count()).toBe(0) await slashDialog.getByRole('checkbox', { name: '我已了解这些风险,并愿意继续' }).check() - await slashDialog.getByRole('button', { name: '启用 Auto review' }).click() + await slashDialog.getByRole('button', { name: '启用自动审查' }).click() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) - .toBe('访问模式,当前:Auto review EXP') + .toBe('访问模式,当前:自动审查') expect(await slashDialog.count()).toBe(0) expect(await input.textContent()).toBe('') await captureAutoReviewState(page, 'experimental-current-session') @@ -188,12 +188,18 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { expect(menuBox.width).toBeLessThanOrEqual(360) expect(menuBox.x).toBeGreaterThanOrEqual(0) expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(420) - for (const name of ['仅可查看', '工作区内修改', '完全权限', 'Auto review']) { + for (const name of ['仅可查看', '工作区内修改', '完全权限', '自动审查']) { expect(await menu.getByText(name, { exact: true }).evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true) } await page.keyboard.press('Escape') const input = page.locator('[data-composer-input]').first() + await writeComposerDraft(page, input, '/permission auto') + await input.press('Enter') + await expect.poll(() => access.getAttribute('aria-label')).toBe('访问模式,当前:自动审查') + expect(await access.getByText('自动审查', { exact: true }).isVisible()).toBe(false) + expect(await access.getByText('EXP', { exact: true }).count()).toBe(0) + expect(await access.getAttribute('title')).toBe('无沙箱运行;每次原生工具调用和 PTC 内层调用前由同一模型进行实验性审查。') await writeComposerDraft(page, input, '/permission') await input.press('Escape') await input.press('Enter') @@ -206,10 +212,13 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { expect(slashBox.x + slashBox.width).toBeLessThanOrEqual(420) expect(await slash.evaluate(node => getComputedStyle(node).minWidth)).toBe('min(220px, 100%)') expect(await slash.evaluate(node => getComputedStyle(node).maxWidth)).toBe('100%') - for (const name of ['仅可查看', '工作区内修改', '完全权限', 'Auto review']) { + for (const name of ['仅可查看', '工作区内修改', '完全权限', '自动审查']) { expect(await slash.getByText(name, { exact: true }).evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true) } await page.keyboard.press('Escape') + await writeComposerDraft(page, input, '/permission danger-full-access') + await input.press('Enter') + await expect.poll(() => access.getAttribute('aria-label')).toBe('访问模式,当前:完全权限') expect(tripwire.pageErrors).toEqual([]) }) @@ -219,7 +228,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { const access = page.locator('button[aria-label^="访问模式"]').first() await writeComposerDraft(page, input, '/permission auto') await input.press('Enter') - await expect.poll(() => access.getAttribute('aria-label')).toBe('访问模式,当前:Auto review EXP') + await expect.poll(() => access.getAttribute('aria-label')).toBe('访问模式,当前:自动审查') expect(await page.getByRole('dialog').count()).toBe(0) await writeComposerDraft(page, input, '/permission') await input.press('Escape') @@ -237,7 +246,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await input.press('Enter') await slash.waitFor() expect(await slash.getByRole('option').count()).toBe(3) - expect(await slash.getByText('Auto review', { exact: true }).count()).toBe(0) + expect(await slash.getByText('自动审查', { exact: true }).count()).toBe(0) await page.keyboard.press('Escape') await access.click() await expect.poll(() => page.getByRole('menuitem').allTextContents()) @@ -248,7 +257,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await scaffold.ctx.loader.await() await access.click() await expect.poll(() => page.getByRole('menuitem').allTextContents()) - .toEqual(['仅可查看', '工作区内修改', '完全权限', 'Auto reviewEXP']) + .toEqual(['仅可查看', '工作区内修改', '完全权限', '自动审查EXP']) expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:完全权限') await captureAutoReviewState(page, 'reinstalled-current-session-picker') await page.keyboard.press('Escape') @@ -302,7 +311,7 @@ describe('web e2e: default permission choices', () => { const slash = page.locator('[aria-label="/permission 选项"]') await slash.waitFor() expect(await slash.getByRole('option').count()).toBe(3) - expect(await slash.getByText('Auto review', { exact: true }).count()).toBe(0) + expect(await slash.getByText('自动审查', { exact: true }).count()).toBe(0) await captureAutoReviewState(page, 'default-slash-picker') }) }) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 6fc12aba02..61a9a2ad34 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -455,8 +455,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() await page.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click() + // The Creator entry reuses the draft's blank Session and names its preset in the creation request. await Promise.all([ - page.waitForResponse('**/api/agentPresets/select'), + page.waitForResponse('**/api/session/create'), page.getByRole('menuitem', { name: /^Let the agent create a plugin/ }).click(), ]) const picker = page.getByTitle('Choose the agent preset for your new task', { exact: true }).filter({ hasText: 'Creator mode' }) @@ -469,8 +470,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis') expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name)) .toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager'])) - expect(creator.session.snapshotEvents().some(event => event.type === 'agent-preset/selected' - && event.data.agentPreset === 'cordis')).toBe(true) + // The chip may also select Creator once the list updates; the registry records the switch only once. + expect(creator.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected' + && event.data.agentPreset === 'cordis')).toHaveLength(1) expect(creator.session.snapshotEvents().some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools }) expect(scaffold.ctx.agentPresets.defaultId).toBe('standard') diff --git a/apps/web/tests/auto-review-approval.e2e.ts b/apps/web/tests/auto-review-approval.e2e.ts index b430de35f4..890ed25e0e 100644 --- a/apps/web/tests/auto-review-approval.e2e.ts +++ b/apps/web/tests/auto-review-approval.e2e.ts @@ -63,7 +63,7 @@ describe.skipIf(MODE === 'record')('web e2e: cold Auto-review user rejection and const access = page.locator('button[aria-label^="Access mode"]').first() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) - .toBe('Access mode, current: Auto review EXP') + .toBe('Access mode, current: Auto review') const collapsed = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/apps/web/tests/auto-review-denial.e2e.ts b/apps/web/tests/auto-review-denial.e2e.ts index b792361be5..58371631a9 100644 --- a/apps/web/tests/auto-review-denial.e2e.ts +++ b/apps/web/tests/auto-review-denial.e2e.ts @@ -92,7 +92,7 @@ describe.skipIf(MODE === 'record')('web e2e: cold Auto-review denial', () => { const access = page.locator('button[aria-label^="Access mode"]').first() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) - .toBe('Access mode, current: Auto review EXP') + .toBe('Access mode, current: Auto review') await captureAutoReviewState(page, 'deny-collapsed') const collapsed = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/blank-preset-reuse.e2e.ts b/apps/web/tests/blank-preset-reuse.e2e.ts new file mode 100644 index 0000000000..5f9958ab5f --- /dev/null +++ b/apps/web/tests/blank-preset-reuse.e2e.ts @@ -0,0 +1,131 @@ +/** Blank Session reuse under the requested or default Agent preset through the shipped Web composition. */ + +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +/** A preset these blank Sessions recorded that the deployment no longer declares. */ +const UNDECLARED = 'liangshen' + +describe('web e2e: blank Session reuse under the requested preset', () => { + let scaffold: WebScaffold + let browser: Browser + const tripwires: ReturnType[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + browser = await chromium.launch() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** Store a blank Session recorded under `agentPreset`, with a plan-mode switch, as the only member of a new Workspace. */ + async function storeBlank(sessionId: SessionId, folder: string, agentPreset: string): Promise { + const path = join(scaffold.workspaceCwd, folder) + await mkdir(path) + const workspace = await scaffold.ctx.workspaceRegistry.create(path) + const handle = await scaffold.ctx.agents.create({ + sessionId, + meta: { cwd: path, agentPreset }, + agentOptions: scaffold.ctx.agentDefaultModel.currentSelection(), + }) + try { + handle.agent.session.append('plan/mode', { active: true }) + await workspace.attachSession(sessionId) + } finally { + await handle.dispose() + } + } + + async function openPage(saved?: SessionId): Promise { + const page = await newEnglishPage(browser) + tripwires.push(watchConsole(page)) + if (saved !== undefined) { + await page.addInitScript((id) => { + localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: id })) + }, saved) + } + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[data-composer-input]').first().waitFor({ timeout: 30_000 }) + return page + } + + async function selected(page: Page): Promise { + return page.evaluate(() => { + const value = localStorage.getItem('dsh.sessions.current') + return value === null ? undefined : (JSON.parse(value) as { sessionId?: string }).sessionId + }) + } + + /** The live Agent's mounted preset, its projected preset, and its plan-mode state. */ + function liveComposition(sessionId: SessionId) { + const agent = scaffold.ctx.agents.get(sessionId) + if (agent === undefined) return undefined + return { + mounted: scaffold.ctx.agentPresets.composedPreset(agent.ctx), + projected: scaffold.ctx.sessionProjections.stateOf(agent.session, 'agentPreset'), + plan: scaffold.ctx.sessionProjections.stateOf(agent.session, 'plan'), + } + } + + async function storedSelections(sessionId: SessionId): Promise { + const handle = await scaffold.ctx.sessionPersistence.open(sessionId, 'read') + try { + const { events } = await handle.read() + return events + .filter((event: SessionEvent) => event.type === 'agent-preset/selected') + .map((event: SessionEvent) => event.data) + } finally { + await handle.close() + } + } + + it('reuses a blank recorded under an undeclared preset under the default when startup connects its Workspace', async () => { + const stale = SessionId('blank-undeclared') + await storeBlank(stale, 'undeclared', UNDECLARED) + + const page = await openPage() + await expect.poll(() => selected(page)).toBe(stale) + await expect.poll(() => liveComposition(stale)).toMatchObject({ + mounted: 'standard', projected: 'standard', plan: { active: true }, + }) + expect(await storedSelections(stale)).toEqual([{ agentPreset: 'standard' }]) + expect(scaffold.ctx.workspaceRegistry.list().find(item => item.sessionIds.includes(stale))?.sessionIds).toEqual([stale]) + expect(await page.getByRole('alert').count()).toBe(0) + }) + + it('restores a saved blank under the preset it recorded', async () => { + const picked = SessionId('blank-picked') + await storeBlank(picked, 'picked', 'minimal') + + const page = await openPage(picked) + await expect.poll(() => selected(page)).toBe(picked) + await expect.poll(() => liveComposition(picked)).toMatchObject({ mounted: 'minimal', projected: 'minimal' }) + expect(await storedSelections(picked)).toEqual([]) + }) + + it('restores a saved blank under the default once its recorded preset is undeclared', async () => { + const saved = SessionId('saved-undeclared') + await storeBlank(saved, 'saved-undeclared', UNDECLARED) + + const page = await openPage(saved) + await expect.poll(() => selected(page)).toBe(saved) + await expect.poll(() => liveComposition(saved)).toMatchObject({ + mounted: 'standard', projected: 'standard', plan: { active: true }, + }) + expect(await storedSelections(saved)).toEqual([{ agentPreset: 'standard' }]) + expect(await page.getByRole('alert').count()).toBe(0) + for (const tripwire of tripwires) { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } + }) +}) diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index f645214133..c7b7f41b65 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -97,6 +97,8 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn fireEvent.click(waitingTitle) await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() + expect(document.querySelector('[data-chat-running]')?.parentElement?.getAttribute('data-slot')) + .toBe('conversation.chat.flow') }, { timeout: 10_000 }) // The generated bundle roster mounts the question UI before the approval UI. // Skip the resident fixture's three questions, then resolve its approval so diff --git a/apps/web/tests/chat-flow-layout.e2e.ts b/apps/web/tests/chat-flow-layout.e2e.ts new file mode 100644 index 0000000000..d3d5e999ac --- /dev/null +++ b/apps/web/tests/chat-flow-layout.e2e.ts @@ -0,0 +1,73 @@ +/** Cold history retains Chat spacing through the flow slot's display:contents anchor. */ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { launchWebScaffold, seedSession, selectedSessionFixture, watchConsole } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +it.each(['turn-tail-actions', 'goal-multi-turn-actions', 'present'] as const)( + 'preserves process and answer spacing in cold %s history', async (scenario) => { + const scaffold = await launchWebScaffold({}) + onTestFinished(() => scaffold.close()) + if (scenario === 'present') { + await writeFile(join(scaffold.workspaceCwd, 'report.txt'), 'DELIVERED_REPORT\n') + await writeFile(join(scaffold.workspaceCwd, '说明.txt'), 'DELIVERED_NOTE\n') + } + const fixture = await selectedSessionFixture(fileURLToPath( + new URL(`../../../snapshots/web/${scenario}/session.v3.jsonl`, import.meta.url), + )) + await seedSession(scaffold, await readFile(fixture, 'utf8'), `flow-layout-${scenario}`) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + const controls = page.locator('[data-turn-process]') + await expect.poll(() => controls.count()).toBe(scenario === 'goal-multi-turn-actions' ? 2 : 1) + const process = controls.first() + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await process.evaluate(element => element.closest('[data-slot="conversation.chat.flow"]') !== null)).toBe(true) + + if (scenario === 'goal-multi-turn-actions') { + const trigger = page.locator('[data-turn-trigger]').first() + await process.click() + for (const fontSize of [10, 14, 22]) { + await scaffold.ctx.settings.update('ui-theme', { fontSize }) + await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) + .toBe(`${String(fontSize)}px`) + const group = page.locator('[data-process-activity]:visible').first() + expect(await process.evaluate(element => element.getBoundingClientRect().top)) + .toBe(await trigger.evaluate(element => element.getBoundingClientRect().bottom) + 16) + expect(await group.evaluate(element => element.getBoundingClientRect().top)) + .toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16) + } + } else { + const answer = page.getByText(scenario === 'present' ? 'PRESENT_DONE' : 'DONE', { exact: true }) + await answer.waitFor() + const answerBounds = await answer.evaluate((element) => { + const seat = element.closest('[data-chat-flow-kind="assistant-step"]') + if (seat === null) throw new Error('final answer has no Chat seat') + const { top, bottom } = seat.getBoundingClientRect() + return { top, bottom } + }) + expect(answerBounds.top).toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16) + if (scenario === 'present') { + const geometry = await page.locator('[data-presented-files-row]').evaluate((grid) => { + const root = grid.parentElement + const tail = root?.closest('[data-turn-tail]') + const actions = tail?.querySelector('button[aria-label="Copy"]')?.parentElement + if (root === null || actions == null) throw new Error('presented files have no Turn footer') + return { top: root.getBoundingClientRect().top, bottom: root.getBoundingClientRect().bottom, + actionsTop: actions.getBoundingClientRect().top } + }) + expect(geometry.top - answerBounds.bottom).toBeCloseTo(16, 1) + expect(geometry.actionsTop - geometry.bottom).toBeCloseTo(20, 1) + } + } + expect(console.pageErrors).toEqual([]) + }, +) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 4d22e66e98..955d251ab7 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -2,18 +2,19 @@ // deliberately virtualizer-neutral: they assert semantic-row position, // bottom ownership, interaction state, and the real outer scroll host rather // than DOM cardinality or implementation-specific spacer markup. -import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import { fileURLToPath } from 'node:url' +import type { Browser, Page, Route } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, @@ -27,7 +28,7 @@ import { expandOwningTurnProcess, newEnglishPage, openSettings, saveFailureShot const MODE = webSnapshotMode() const HISTORY_SESSION_ID = 'chat-scroll-history-e2e' -const TOOL_SESSION_ID = 'chat-scroll-tool-e2e' +const TOOL_SESSION_ID = SessionId('chat-scroll-tool-e2e') const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e' const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e' const REPLAY_CONTEXT_WINDOW = 10_000_000 @@ -46,7 +47,7 @@ const TOOL_READY_FILE = '.chat-scroll-tool-ready' const TOOL_RELEASE_FILE = '.chat-scroll-tool-release' const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e' const RAIL_SESSION_ID = 'chat-scroll-rail-e2e' -const FLING_SESSION_ID = 'chat-scroll-fling-e2e' +const FLING_SESSION_ID = SessionId('chat-scroll-fling-e2e') const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.' const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST' const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE' @@ -73,6 +74,22 @@ const INPUTS_FIXTURE = createChatScrollFixture({ title: 'CHAT_SCROLL_INPUTS non-wheel reader input session', }) +interface FoldMotionSample { + readonly t: number + readonly ordinaryGrowth: boolean + readonly top: number + readonly bodyY: number + readonly earlierY: number + readonly messageY: number | null + readonly stepY: number | null + readonly flowHeight: number + readonly height: number + readonly spacer: number + readonly folding: boolean + readonly raceApplied: boolean + readonly composerTop: number +} + interface ScrollGeometry { readonly distanceFromBottom: number readonly scrollTop: number @@ -94,6 +111,8 @@ interface ScrollWorld { interface ScrollWorldOptions { readonly failureShot: string + /** Install a controllable browser clock before boot; omitted worlds use real time. */ + readonly controlledClock?: boolean readonly paceMs?: number readonly replay?: ReplayOverrideDoc readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[] @@ -189,6 +208,7 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise { events.push(event) }) scaffold.ctx.on('agent/assistant-stream', ({ frame }) => { assistantFrames.push(frame) }) page = await newEnglishPage(browser, 900) + if (options.controlledClock) await page.clock.install() const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -524,6 +544,675 @@ describe('web e2e: long Chat scroll contract', () => { await browser?.close() }) + for (const scenario of [ + { name: 'body-only', bodyParagraphs: 32, inputLines: 1, reduced: false }, + { name: 'visible-steps', bodyParagraphs: 2, inputLines: 1, reduced: false }, + { name: 'large-input', bodyParagraphs: 2, inputLines: 18, reduced: false }, + { name: 'reduced-motion', bodyParagraphs: 2, inputLines: 1, reduced: true }, + { name: 'cancel-all-during-fold', bodyParagraphs: 2, inputLines: 1, reduced: false }, + { name: 'content-growth-during-fold', bodyParagraphs: 32, inputLines: 1, reduced: false }, + ]) { + it.skipIf(MODE === 'record')(`uses only visible fold room in one upward movement: ${scenario.name}`, async () => { + const base = createChatScrollFixture({ markerPrefix: 'VISIBLE_FOLD', title: `CHAT_VISIBLE_FOLD_${scenario.name}`, turns: 8 }) + const parsed = parseSeedFixture(base.log) + const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === 8) + const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = [ + ...parsed.events.slice(0, boundary), + { type: 'turn/start', data: { turn: 8 } }, + { type: 'user/message', data: createUserMessage({ + content: [{ type: 'text', text: 'VISIBLE_FOLD_EARLIER_ANCHOR' }], source: { kind: 'user' }, + }), surfaceOp: 'append' }, + ] + for (let step = 1; step <= 6; step++) { + drafts.push({ type: 'step/start', data: { turn: 8, step } }) + drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: { + turn: 8, step, stream: [], message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: `VISIBLE_FOLD_STEP_${step}\n\n${Array.from({ length: 18 }, (_, line) => + `Reasoning stage ${step}, paragraph ${line}: retained work details explain this intermediate decision.`).join('\n\n')}` }, + { type: 'text', text: `VISIBLE_FOLD_PROGRESS_${step}` }, + ], source: { provider: 'fixture', model: 'fixture' }, + }), + } }) + drafts.push({ type: 'step/end', data: { turn: 8, step } }) + } + const bodyMarker = 'VISIBLE_FOLD_BODY' + drafts.push({ type: 'step/start', data: { turn: 8, step: 7 } }) + drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: { + turn: 8, step: 7, stream: [], message: createAssistantMessage({ + content: [{ type: 'text', text: `${bodyMarker}\n\n${Array.from({ length: scenario.bodyParagraphs }, (_, line) => + `Final answer paragraph ${line}: this content remains after the preceding work details fold.`).join('\n\n')}` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + } }) + drafts.push({ type: 'step/end', data: { turn: 8, step: 7 } }, + { type: 'turn/end', data: { turn: 8, reason: { kind: 'completed' } } }) + const fixture: ChatScrollFixture = { ...base, log: [parsed.headerLine, + ...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n') } + const input = Array.from({ length: scenario.inputLines }, (_, index) => `VISIBLE_FOLD_NEXT_${index}`).join('\n') + const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/visible-fold/', import.meta.url)) + await mkdir(artifactRoot, { recursive: true }) + const evidence = await mkdtemp(join(artifactRoot, `${scenario.name}-`)) + const controlledClock = scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold' + await withScrollWorld({ + failureShot: `visible-fold-${scenario.name}`, + controlledClock, + replay: [replayEntry(textStream('VISIBLE_FOLD_REPLY', 'VISIBLE_FOLD_DONE', 2))], + seeds: [{ fixture, id: `visible-fold-${scenario.name}` }], + }, async (world) => { + const { page } = world + await page.setViewportSize({ width: 1280, height: 720 }) + await page.emulateMedia({ reducedMotion: scenario.reduced ? 'reduce' : 'no-preference' }) + await openSeed(page, fixture, bodyMarker) + await openSettings(page, 'en') + const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..') + await timing.getByRole('button').click() + await page.getByRole('menuitem', { name: 'On next message', exact: true }).click() + const details = dialog.getByText('Work details', { exact: true }).locator('../..') + await details.getByRole('button').click() + await page.getByRole('menuitem', { name: 'Detailed', exact: true }).click() + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + const thoughts = page.locator('[data-chat-turn="8"] [data-variant="think"]') + await expect.poll(() => thoughts.count()).toBe(6) + for (let index = 0; index < 6; index++) { + const button = thoughts.nth(index).getByRole('button').first() + if (await button.getAttribute('aria-expanded') !== 'true') await button.click() + } + await wheelTranscript(page, -600) + await page.getByRole('button', { name: 'Back to bottom', exact: true }).click() + await expectBottom(page) + await nextPaint(page) + await page.locator('[data-composer-input][contenteditable="true"]').last().fill(input) + await nextPaint(page) + if (scenario.name === 'visible-steps') { + const offset = 20 + await thoughts.last().getByRole('button').first().evaluate((button, offset) => { + const host = button.closest('[data-conversation-scroll]')! + const group = button.closest('[data-chat-group-key]')! + host.scrollTop += group.getBoundingClientRect().top - host.getBoundingClientRect().top - offset + host.dispatchEvent(new Event('scroll')) + }, offset) + await nextPaint(page) + } + const before = await page.locator('[data-conversation-scroll]').evaluate((host) => { + const viewport = host.getBoundingClientRect() + const composer = host.querySelector('[data-composer-seat]')! + const body = [...host.querySelectorAll('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)! + const groups = [...host.querySelectorAll('[data-chat-turn="8"][data-chat-group-key]')] + const candidates = [...host.querySelectorAll('[data-chat-turn="8"][data-chat-group-key], [data-chat-turn="8"][data-turn-process-member="true"]')] + .filter(element => !element.parentElement?.closest('[data-chat-turn="8"][data-chat-group-key], [data-chat-turn="8"][data-turn-process-member="true"]')) + return { top: host.scrollTop, height: host.scrollHeight, viewportTop: viewport.top, + removed: candidates.map((element) => { + const rect = element.getBoundingClientRect() + const margin = Number.parseFloat(getComputedStyle(element).marginTop) || 0 + return { top: rect.top - viewport.top - margin, height: rect.height + margin } + }), + floor: Math.max(0, host.scrollHeight - host.clientHeight), + viewport: host.clientHeight, visibleHeight: composer.getBoundingClientRect().top - viewport.top, + bodyTop: body.getBoundingClientRect().top, bodyHeight: body.getBoundingClientRect().height, + flowHeight: host.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!.getBoundingClientRect().height, + groupHeight: groups.reduce((sum, group) => sum + group.getBoundingClientRect().height, 0), + visibleSteps: groups.reduce((sum, group) => { + const rect = group.getBoundingClientRect() + return sum + Math.max(0, Math.min(rect.bottom, composer.getBoundingClientRect().top) - Math.max(rect.top, viewport.top)) + }, 0) } + }) + expect(before.groupHeight).toBeGreaterThan(before.visibleHeight * 3) + if (scenario.bodyParagraphs === 32) expect(before.visibleSteps).toBe(0) + else expect(before.visibleSteps).toBeGreaterThan(0) + if (scenario.name === 'cancel-all-during-fold') { + await openSettings(page, 'en') + const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) + await settings.getByText('Work details', { exact: true }).locator('../..').getByRole('button').click() + await page.getByRole('menuitem', { name: 'Verbose', exact: true }).waitFor() + } + const release = holdTextAfter(world, 0) + const finishGate = Promise.withResolvers() + const finishBlocked = Promise.withResolvers() + const holdFinish = world.scaffold.ctx.on('llm/stream', async function* (_options, next) { + for await (const chunk of next()) { + if (chunk.type === 'finish') { + finishBlocked.resolve(undefined) + await finishGate.promise + } + yield chunk + } + }) + const promptGate = Promise.withResolvers() + const promptBlocked = Promise.withResolvers() + const holdPrompt = async (route: Route): Promise => { + promptBlocked.resolve(undefined) + await promptGate.promise + await route.continue() + } + await page.route('**/api/session/prompt', holdPrompt) + const trace = page.evaluate(({ marker, race }) => new Promise((resolveTrace) => { + const host = document.querySelector('[data-conversation-scroll]')! + const column = host.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')! + const body = [...column.querySelectorAll('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)! + const earlier = [...column.querySelectorAll('*')].find(element => + element.children.length === 0 && element.textContent === 'VISIBLE_FOLD_EARLIER_ANCHOR')! + const samples: FoldMotionSample[] = [] + const visibleStep = [...column.querySelectorAll('[data-chat-turn="8"] [data-variant="think"] [role="button"]')].at(-1) + let frame = 0 + let ordinaryGrowth = false + let raceApplied = false + let closingHeight: number | undefined + const markGrowth = (): void => { ordinaryGrowth = true } + host.addEventListener('dsh-test-fold-growth', markGrowth) + const stepPosition = (): number | null => { + if (visibleStep === undefined || !visibleStep.isConnected || visibleStep.closest('[hidden]') !== null) return null + const rect = visibleStep.getBoundingClientRect() + if (rect.height === 0 || rect.width === 0) return null + const walker = document.createTreeWalker(visibleStep, NodeFilter.SHOW_TEXT) + let textNode = walker.nextNode() + while (textNode !== null && !(textNode.textContent ?? '').trim()) textNode = walker.nextNode() + if (textNode === null) return null + const range = document.createRange() + range.selectNodeContents(textNode) + const glyph = range.getBoundingClientRect() + return glyph.height === 0 ? null : glyph.top + } + const sample = (): void => { + const closing = [...column.querySelectorAll('[data-chat-motion="collapse"]')] + .filter(element => element.parentElement?.closest('[data-chat-motion="collapse"]') === null) + const height = closing.reduce((sum, element) => sum + element.getBoundingClientRect().height, 0) + if (closing.length > 0) closingHeight ??= height + if (!raceApplied && closingHeight !== undefined && height > 0 && height < closingHeight - 1) { + if (race === 'cancel-all-during-fold') { + const verbose = [...document.querySelectorAll('[role="menuitem"]')] + .find(element => element.textContent?.trim() === 'Verbose') + if (verbose !== undefined) { + raceApplied = true + verbose.click() + } + } else if (race === 'content-growth-during-fold') { + // Controlled layout growth exercises ResizeObserver overlap, not Session-event delivery. + raceApplied = true + column.style.paddingBottom = '320px' + } + if (raceApplied) host.setAttribute('data-fold-race-applied', '') + } + const message = [...column.querySelectorAll('[data-submission-echo], [data-chat-flow-kind="user"]')] + .find(element => element.textContent?.includes(marker)) + const step = stepPosition() + samples.push({ t: performance.now(), ordinaryGrowth, top: host.scrollTop, bodyY: body.getBoundingClientRect().top, + earlierY: earlier.getBoundingClientRect().top, messageY: message?.getBoundingClientRect().top ?? null, + stepY: step, + flowHeight: column.getBoundingClientRect().height, height: host.scrollHeight, + spacer: host.querySelector('[data-chat-turn-spacer]')!.getBoundingClientRect().height, + folding: column.querySelector('[data-chat-motion="collapse"]') !== null, raceApplied, + composerTop: host.querySelector('[data-composer-seat]')!.getBoundingClientRect().top }) + } + const tick = (): void => { sample(); frame = requestAnimationFrame(tick) } + host.addEventListener('dsh-test-fold-stop', () => { + cancelAnimationFrame(frame); sample() + host.removeEventListener('dsh-test-fold-growth', markGrowth) + host.removeAttribute('data-fold-race-applied') + host.removeAttribute('data-fold-probe-ready'); resolveTrace(samples) + }, { once: true }) + sample(); host.setAttribute('data-fold-probe-ready', ''); frame = requestAnimationFrame(tick) + }), { marker: 'VISIBLE_FOLD_NEXT_0', race: scenario.name }) + await page.locator('[data-fold-probe-ready]').waitFor() + let settled: ReturnType | undefined + let samples: Awaited = [] + try { + settled = world.scaffold.whenTurnSettled(30_000) + if (controlledClock) { + // Advance every animation frame so the race cannot miss the intermediate fold height. + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)) + try { + await page.getByRole('button', { name: 'Send message', exact: true }).evaluate((button) => { + if (!(button instanceof HTMLButtonElement)) throw new Error('Send control is not a button') + button.click() + }) + await expect.poll(async () => { + await page.clock.runFor(16) + return page.locator('[data-fold-race-applied]').count() + }, { timeout: 10_000 }).toBe(1) + await expect.poll(async () => { + await page.clock.runFor(16) + return page.locator('[data-chat-motion="collapse"]').count() + }, { timeout: 10_000 }).toBe(0) + } finally { + await page.clock.resume() + } + } else await page.getByRole('button', { name: 'Send message', exact: true }).click() + await promptBlocked.promise + await page.getByText(input, { exact: true }).waitFor() + await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0) + if (scenario.name !== 'cancel-all-during-fold') await expectBottom(page) + await page.locator('[data-conversation-scroll]').evaluate(async (host) => { + let previous = host.scrollTop + let stableSince = performance.now() + await new Promise((resolveStable) => { + const check = (): void => { + const now = performance.now() + if (Math.abs(host.scrollTop - previous) > 0.25) stableSince = now + previous = host.scrollTop + if (now - stableSince >= 240) resolveStable() + else requestAnimationFrame(check) + } + requestAnimationFrame(check) + }) + }) + await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-growth'))) + promptGate.resolve(undefined) + release() + await finishBlocked.promise + await page.getByText('VISIBLE_FOLD_DONE', { exact: false }).waitFor() + await expectBottom(page) + await nextPaint(page) + } finally { + promptGate.resolve(undefined) + release() + await page.unroute('**/api/session/prompt', holdPrompt) + await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop'))) + samples = await trace + finishGate.resolve(undefined) + holdFinish() + await settled + } + const allSamples = samples + const ordinarySamples = samples.filter(sample => sample.ordinaryGrowth) + samples = samples.filter(sample => !sample.ordinaryGrowth) + const first = samples[0]! + const last = samples.at(-1)! + const closedBoxesHeight = before.removed.reduce((sum, box) => sum + box.height, 0) + const removedHeight = first.bodyY + first.top - last.bodyY - last.top + const boundaryGrowth = closedBoxesHeight - removedHeight + const aboveRemoved = Math.min(removedHeight, + before.removed.reduce((sum, box) => sum + Math.min(box.height, Math.max(0, -box.top)), 0)) + const remainingHeight = last.height - last.spacer + const newHeight = remainingHeight - before.height + removedHeight + const baseTop = Math.max(0, before.top - aboveRemoved) + const expectedTop = Math.max(baseTop, remainingHeight - before.viewport, 0) + const expectedSpacer = expectedTop === 0 ? 0 : Math.max(0, expectedTop + before.viewport - remainingHeight) + const ordinaryFollow = expectedTop - baseTop + const expectedBodyShift = removedHeight - aboveRemoved + ordinaryFollow + const bodyShift = first.bodyY - last.bodyY + const reversalOf = (values: readonly number[]): number => { + let lowest = values[0] ?? 0 + let reversal = 0 + for (const value of values) { + lowest = Math.min(lowest, value) + reversal = Math.max(reversal, value - lowest) + } + return reversal + } + const reversal = reversalOf(samples.map(sample => sample.bodyY)) + const inputReversal = reversalOf(samples.flatMap(sample => sample.messageY === null ? [] : [sample.messageY])) + const ordinaryBodyReversal = reversalOf([samples.at(-1)!.bodyY, ...ordinarySamples.map(sample => sample.bodyY)]) + const ordinaryInputReversal = reversalOf(ordinarySamples.flatMap(sample => sample.messageY === null ? [] : [sample.messageY])) + const stepReversal = reversalOf(samples.flatMap(sample => sample.stepY === null ? [] : [sample.stepY])) + const firstFoldIdle = samples.findIndex((sample, index) => index > 0 && !sample.folding && samples[index - 1]!.folding) + const afterFold = firstFoldIdle < 0 ? [] : samples.slice(firstFoldIdle) + const range = (values: number[]): number => values.length === 0 ? 0 : Math.max(...values) - Math.min(...values) + const foldBoundaryMovement = firstFoldIdle > 0 + ? Math.abs(samples[firstFoldIdle]!.bodyY - samples[firstFoldIdle - 1]!.bodyY) : 0 + const priorFrameMovements = samples.slice(1, Math.max(1, firstFoldIdle - 1)) + .map((sample, index) => Math.abs(sample.bodyY - samples[index]!.bodyY)) + const secondMovement = range(afterFold.map(sample => sample.bodyY)) + const secondInputMovement = range(afterFold.flatMap(sample => sample.messageY === null ? [] : [sample.messageY])) + const earlierShift = first.earlierY - last.earlierY + await writeFile(join(evidence, 'motion.json'), JSON.stringify({ scenario, before, closedBoxesHeight, removedHeight, boundaryGrowth, aboveRemoved, + remainingHeight, newHeight, expectedTop, expectedSpacer, expectedBodyShift, bodyShift, ordinaryFollow, + earlierShift, reversal, inputReversal, stepReversal, secondMovement, secondInputMovement, + ordinaryBodyReversal, ordinaryInputReversal, samples: allSamples }, null, 2)) + console.log(`visible-fold ${scenario.name}: spacer=${last.spacer}/${expectedSpacer}, top=${last.top}/${expectedTop}, reversal=${reversal}, secondMovement=${secondMovement}, evidence=${evidence}`) + if (scenario.name === 'cancel-all-during-fold') { + expect(samples.some(sample => sample.raceApplied)).toBe(true) + expect(last.folding).toBe(false) + expect(last.spacer).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect(await page.getByRole('dialog', { name: 'Settings', exact: true }) + .getByText('Work details', { exact: true }).locator('../..') + .getByRole('button', { name: 'Verbose', exact: true }).count()).toBe(1) + expect(await page.locator('[data-input-fold]').count()).toBe(0) + assertClean(world) + return + } + if (scenario.name === 'content-growth-during-fold') { + expect(samples.some(sample => sample.raceApplied && sample.folding)).toBe(true) + expect.soft(foldBoundaryMovement).toBeLessThanOrEqual(Math.max(...priorFrameMovements) + GEOMETRY_TOLERANCE) + } + expect.soft(removedHeight).toBeGreaterThan(before.visibleHeight * 3) + if (!scenario.reduced) { + expect.soft(samples.some(sample => sample.folding)).toBe(true) + const movingSamples = samples.filter((sample, index) => index > 0 + && Math.abs(sample.bodyY - samples[index - 1]!.bodyY) > 0.25) + expect.soft(movingSamples.length).toBeGreaterThan(1) + expect.soft(firstFoldIdle).toBeGreaterThan(0) + } + if (scenario.name === 'visible-steps') { + expect.soft(first.stepY).not.toBeNull() + expect.soft(first.stepY!).toBeGreaterThanOrEqual(before.viewportTop) + expect.soft(first.stepY!).toBeLessThan(first.composerTop) + const initial = samples.filter(sample => sample.stepY !== null && sample.t <= samples[firstFoldIdle - 1]!.t) + expect.soft(Math.max(...initial.map(sample => sample.stepY!)) - first.stepY!).toBeLessThanOrEqual(0.5) + } + expect.soft(Math.abs(last.top - expectedTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(Math.abs(last.spacer - expectedSpacer)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(Math.abs(bodyShift - expectedBodyShift)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(reversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(inputReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(stepReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(ordinarySamples.length).toBeGreaterThan(0) + expect.soft(ordinaryBodyReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(ordinaryInputReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(secondMovement).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(secondInputMovement).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + if (first.earlierY >= before.viewportTop) { + expect.soft(Math.abs(earlierShift - ordinaryFollow)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + } + if (scenario.name === 'body-only' || scenario.name === 'large-input') expect.soft(last.spacer).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(last.messageY).not.toBeNull() + expect.soft(last.messageY!).toBeLessThan(last.composerTop) + assertClean(world) + }) + }, 180_000) + } + + it.skipIf(MODE === 'record')('resets transcript height when switching from Verbose back to Standard during a reply', async () => { + const fixture = createChatScrollFixture({ markerPrefix: 'MODE', title: 'CHAT_MODE_HEIGHT', turns: 8 }) + await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/chat-mode-height/', import.meta.url)), { recursive: true }) + await withScrollWorld({ + failureShot: 'screenshots/chat-mode-height/web-e2e-chat-mode-height', + replay: [replayEntry(textStream('CHAT_MODE_REPLY', 'CHAT_MODE_DONE', 2))], + seeds: [{ fixture, id: 'chat-mode-height-e2e' }], + }, async (world) => { + const { page } = world + await page.setViewportSize({ width: 1280, height: 720 }) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + const selectPreference = async (title: string, choice: string): Promise => { + await openSettings(page, 'en') + const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + const row = dialog.getByText(title, { exact: true }).locator('../..') + await row.getByRole('button').click() + await page.getByRole('menuitem', { name: choice, exact: true }).click() + await row.getByRole('button', { name: choice, exact: true }).waitFor() + if (title === 'Work details') { + await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value) + .toMatchObject({ transcriptView: choice.toLowerCase() }) + } + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + await expect.poll(() => page.locator('[data-chat-motion="collapse"], [data-chat-motion="reveal"]').count()).toBe(0) + await nextPaint(page) + } + const returnToBottom = async (): Promise => { + await wheelTranscript(page, -600) + await page.getByRole('button', { name: 'Back to bottom', exact: true }).click() + await expectBottom(page) + } + const geometry = () => page.locator('[data-conversation-scroll]').evaluate(host => ({ + height: host.scrollHeight, + viewport: host.clientHeight, + spacer: host.querySelector('[data-chat-turn-spacer]')!.getBoundingClientRect().height, + })) + await selectPreference('Work details', 'Standard') + await openSeed(page, fixture, fixture.markers.assistant(8)) + // Hold a live reply so preference changes exercise the same fold animation as incoming content. + const release = holdTextAfter(world, 1) + let replyStarted = false + try { + await page.locator('[data-composer-input][contenteditable="true"]').last().fill('CHAT_MODE_NEXT_INPUT') + await page.getByRole('button', { name: 'Send message', exact: true }).click() + await page.getByText('CHAT_MODE_REPLY', { exact: false }).waitFor() + replyStarted = true + await selectPreference('When to Collapse Work Details', 'On next message') + await page.locator('[data-chat-flow][data-chat-motion=""]').waitFor() + await returnToBottom() + const standard = await geometry() + expect(standard.height).toBeGreaterThan(standard.viewport) + expect(standard.spacer).toBe(0) + for (let cycle = 0; cycle < 2; cycle += 1) { + await selectPreference('Work details', 'Verbose') + await expect.poll(async () => (await geometry()).height) + .toBeGreaterThan(standard.height + GEOMETRY_TOLERANCE) + await returnToBottom() + await selectPreference('Work details', 'Standard') + await expect.poll(async () => (await geometry()).spacer).toBe(0) + await expect.poll(async () => Math.abs((await geometry()).height - standard.height)) + .toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + await expectBottom(page) + await expectMarkerAboveComposer(page, 'CHAT_MODE_REPLY') + } + } finally { + const settled = replyStarted ? world.scaffold.whenTurnSettled() : undefined + release() + await settled + } + assertClean(world) + }) + }) + + it.skipIf(MODE === 'record')('keeps a running Session stationary when returning with output held', async () => { + const fixtureA = createChatScrollFixture({ markerPrefix: 'RETURN_A', title: 'CHAT_RUNNING_RETURN_A' }) + const fixtureB = createChatScrollFixture({ markerPrefix: 'RETURN_B', title: 'CHAT_RUNNING_RETURN_B', turns: 32 }) + const heldMarker = 'CHAT_RUNNING_RETURN_HELD' + const grownMarker = 'CHAT_RUNNING_RETURN_GROWN' + const doneMarker = 'CHAT_RUNNING_RETURN_DONE' + const paragraphs = (count: number, prefix: string): string => Array.from({ length: count }, (_, index) => + `${prefix} paragraph ${index + 1}: ${'The recorded response stays readable while this Session is open. '.repeat(3)}`, + ).join('\n\n') + const parts = [ + `${paragraphs(32, 'Initial output')}\n\n${heldMarker}\n\n`, + `${paragraphs(12, 'Resumed output')}\n\n${grownMarker}\n\n`, + doneMarker, + ] + const chunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + ...parts.map((text): StreamChunk => ({ type: 'text-delta', index: 0, text })), + { type: 'block-end', index: 0, block: { type: 'text', text: parts.join('') } }, + { type: 'usage', usage: { inputTokens: 512, outputTokens: 2_500 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/chat-running-return/', import.meta.url)) + await mkdir(artifactRoot, { recursive: true }) + const evidence = await mkdtemp(join(artifactRoot, 'run-')) + await withScrollWorld({ + failureShot: 'screenshots/chat-running-return/web-e2e-chat-running-return', + replay: [replayEntry(chunks)], + seeds: [ + { fixture: fixtureA, id: 'chat-running-return-a-e2e' }, + { fixture: fixtureB, id: 'chat-running-return-b-e2e' }, + ], + }, async (world) => { + const { page } = world + await page.setViewportSize({ width: 1280, height: 720 }) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await openSettings(page, 'en') + const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + for (const [title, choice] of [ + ['Work details', 'Standard'], + ['When to Collapse Work Details', 'On next message'], + ] as const) { + await dialog.getByText(title, { exact: true }).locator('../..').getByRole('button').click() + await page.getByRole('menuitem', { name: choice, exact: true }).click() + } + await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value) + .toMatchObject({ transcriptView: 'standard' }) + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + await openSeed(page, fixtureA, fixtureA.markers.assistant(fixtureA.turns)) + await expectBottom(page) + + interface ReturnFrame { + frame: number + t: number + sessionA: boolean + markerY: number | null + markerVisible: boolean + scrollTop: number + floor: number + viewportHeight: number + columnHeight: number + spacer: number + motionEnabled: boolean + motion: string[] + following: boolean + running: boolean + responseLength: number | null + } + const sampleReturn = async (marker: string): Promise => { + await openSeed(page, fixtureB, fixtureB.markers.assistant(fixtureB.turns)) + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixtureA.markers.user(1)) + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => result.count()).toBe(1) + // Start before the click; the first visible A frame is part of the assertion, not a setup wait. + const trace = page.evaluate(({ marker, identity }) => new Promise((resolve) => { + const samples: ReturnFrame[] = [] + let frame = 0 + let timer = 0 + let visibleFrames = 0 + let ordinal = 0 + const finish = (): void => { + cancelAnimationFrame(frame) + window.clearTimeout(timer) + document.removeEventListener('dsh-test-return-stop', finish) + document.documentElement.dataset.returnProbe = 'done' + resolve(samples) + } + const sample = (): void => { + const host = [...document.querySelectorAll('[data-conversation-scroll]')] + .find(element => !element.closest('[inert], [hidden]') && element.checkVisibility()) + const column = host?.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])') + if (host !== undefined && column !== null && column !== undefined) { + const viewport = host.getBoundingClientRect() + const composerTop = host.querySelector('[data-composer-seat]')?.getBoundingClientRect().top ?? viewport.bottom + const node = [...column.querySelectorAll('p')] + .find(element => element.textContent?.trim() === marker) + const rect = node?.getBoundingClientRect() + const sessionA = column.textContent.includes(identity) + samples.push({ + frame: ordinal++, t: performance.now(), sessionA, + markerY: rect === undefined ? null : rect.top - viewport.top, + markerVisible: rect !== undefined && rect.bottom > viewport.top && rect.top < composerTop, + scrollTop: host.scrollTop, floor: host.scrollHeight - host.clientHeight, + viewportHeight: host.clientHeight, columnHeight: column.getBoundingClientRect().height, + spacer: host.querySelector('[data-chat-turn-spacer]')?.getBoundingClientRect().height ?? 0, + motionEnabled: column.hasAttribute('data-chat-motion'), + motion: [...column.querySelectorAll('[data-chat-motion]')] + .map(element => element.dataset.chatMotion ?? ''), + following: host.querySelector('[data-chat-following-tail]') !== null, + running: host.querySelector('[data-chat-running]') !== null, + responseLength: node?.closest('[data-chat-flow-key]')?.textContent?.length ?? null, + }) + if (sessionA) visibleFrames += 1 + } + if (visibleFrames >= 60) finish() + else frame = requestAnimationFrame(tick) + } + // A zero-delay task samples after this frame's animation callbacks, including native scrolling. + const tick = (): void => { timer = window.setTimeout(sample, 0) } + document.addEventListener('dsh-test-return-stop', finish, { once: true }) + document.documentElement.dataset.returnProbe = 'ready' + frame = requestAnimationFrame(tick) + }), { marker, identity: 'CHAT_SCROLL_RETURN_A_' }) + try { + await page.locator('html[data-return-probe="ready"]').waitFor() + await result.click() + await page.locator('html[data-return-probe="done"]').waitFor({ timeout: 15_000 }) + } finally { + await page.evaluate(() => { document.dispatchEvent(new Event('dsh-test-return-stop')) }) + await trace + await page.evaluate(() => { delete document.documentElement.dataset.returnProbe }) + } + return trace + } + const gates = [Promise.withResolvers(), Promise.withResolvers()] + const held = [false, false] + const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) { + let index = -1 + for await (const chunk of next()) { + if (chunk.type === 'text-delta') { + const gate = gates[index++] + if (gate !== undefined) { + held[index - 1] = true + await gate.promise + } + } + yield chunk + } + }) + let settled: ReturnType | undefined + await page.context().tracing.start({ screenshots: true, snapshots: true }) + try { + await page.locator('[data-composer-input][contenteditable="true"]').last().fill('CHAT_RUNNING_RETURN_NEXT_INPUT') + await page.getByRole('button', { name: 'Send message', exact: true }).click() + await expect.poll(() => held[0]).toBe(true) + await page.getByText(heldMarker, { exact: true }).waitFor() + await page.locator('[data-chat-running]').waitFor() + await page.locator('[data-chat-flow][data-chat-motion=""]').waitFor() + await expect.poll(() => page.locator('[data-chat-motion="collapse"], [data-chat-motion="reveal"]').count()).toBe(0) + await expectBottom(page) + const before = await scrollGeometry(page) + const chunkCount = () => world.assistantFrames.filter(frame => frame.type === 'chunk').length + const heldChunks = chunkCount() + const running = await sampleReturn(heldMarker) + const chunksAfterReturn = chunkCount() + await writeFile(join(evidence, 'running-frames.json'), JSON.stringify({ before, heldChunks, chunksAfterReturn, samples: running }, null, 2)) + await page.screenshot({ path: join(evidence, 'running-held.png') }) + expect(chunksAfterReturn).toBe(heldChunks) + expect(world.events.some(event => event.type === 'turn/end')).toBe(false) + await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor() + + const beforeGrowth = await scrollGeometry(page) + gates[0]!.resolve(undefined) + await expect.poll(() => held[1]).toBe(true) + await page.getByText(grownMarker, { exact: true }).waitFor() + await expectBottom(page) + const afterGrowth = await scrollGeometry(page) + expect(afterGrowth.scrollTop - beforeGrowth.scrollTop).toBeGreaterThan(200) + expect(await page.locator('[data-chat-following-tail]').count()).toBe(1) + await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor() + await expectMarkerAboveComposer(page, grownMarker) + settled = world.scaffold.whenTurnSettled() + gates[1]!.resolve(undefined) + await settled + await page.getByText(doneMarker, { exact: true }).waitFor() + await expect.poll(() => page.locator('[data-chat-running], [data-streaming="true"]').count()).toBe(0) + await expectBottom(page) + const completed = await sampleReturn(doneMarker) + await page.screenshot({ path: join(evidence, 'completed.png') }) + const summarize = (samples: ReturnFrame[]) => { + const visible = samples.filter(sample => sample.sessionA) + const first = visible[0]! + const markerMovement = Math.max(...visible.map(sample => + sample.markerY === null || first.markerY === null ? Infinity : Math.abs(sample.markerY - first.markerY))) + return { frames: visible.length, markerMovement, first, last: visible.at(-1)! } + } + const summary = { running: summarize(running), completed: summarize(completed) } + await writeFile(join(evidence, 'return-trace.json'), JSON.stringify({ + viewport: { width: 1280, height: 720 }, mode: 'standard', collapseTiming: 'next-input', + historyTurns: { a: fixtureA.turns, b: fixtureB.turns }, initialTextLength: parts[0]!.length, + before, heldChunks, chunksAfterReturn, beforeGrowth, afterGrowth, summary, running, completed, + }, null, 2)) + console.log(`running-return e2e: ${JSON.stringify(summary)}; evidence=${evidence}`) + for (const [name, samples] of [['completed', completed], ['running', running]] as const) { + const visible = samples.filter(sample => sample.sessionA) + expect(visible.length, `${name} return did not capture its first 60 visible frames`).toBe(60) + expect(visible.every(sample => sample.markerY !== null), `${name} return omitted the response in a visible frame`).toBe(true) + expect(visible.every(sample => sample.running === (name === 'running'))).toBe(true) + expect(new Set(visible.map(sample => sample.responseLength)).size, `${name} response changed while output was held`).toBe(1) + expect(summary[name].markerMovement, `${name} Session moved after its first visible frame; trace=${evidence}`) + .toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + } + await expectBottom(page) + assertClean(world) + } finally { + settled ??= world.events.some(event => event.type === 'turn/start') + && !world.events.some(event => event.type === 'turn/end') ? world.scaffold.whenTurnSettled() : undefined + for (const gate of gates) gate.resolve(undefined) + dispose() + try { + await settled + } finally { + await page.context().tracing.stop({ path: join(evidence, 'browser-trace.zip') }) + } + } + }) + }) + it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-floating-controls', @@ -906,6 +1595,9 @@ describe('web e2e: long Chat scroll contract', () => { const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE) const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE) await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns)) + const resolved = await world.scaffold.ctx.sessionController.resolveAgent(TOOL_SESSION_ID) + if ('error' in resolved) throw resolved.error + world.scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') const settled = world.scaffold.whenTurnSettled(60_000) let released = false try { @@ -1138,6 +1830,9 @@ describe('web e2e: long Chat scroll contract', () => { const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE) const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE) await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns)) + const resolved = await world.scaffold.ctx.sessionController.resolveAgent(FLING_SESSION_ID) + if ('error' in resolved) throw resolved.error + world.scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) const settled = world.scaffold.whenTurnSettled(60_000) const releaseText = holdTextAfter(world, 16) diff --git a/apps/web/tests/client-plugin-live.e2e.ts b/apps/web/tests/client-plugin-live.e2e.ts index 21c131a764..db35e15a1f 100644 --- a/apps/web/tests/client-plugin-live.e2e.ts +++ b/apps/web/tests/client-plugin-live.e2e.ts @@ -30,7 +30,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] }) onTestFinished(() => scaffold.close()) const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) - const sessionId = await seedSession(scaffold, await readFile(SESSION_SEED, 'utf8'), 'session-menu-actions-web-e2e') + const sessionId = await seedSession(scaffold, await readFile(SESSION_SEED, 'utf8'), 'session-c4421b66-252e-44c0-80ed-6e320abeba25') await workspace.attachSession(sessionId) await scaffold.ctx.sessionController.rename({ sessionId, title: SESSION_TITLE }) const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' }) @@ -71,13 +71,18 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await row.hover() await trigger.click() await menu.waitFor() - const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session'] + const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session', 'Copy Session ID'] const remainingItems = menu.getByRole('menuitem') expect(await remainingItems.count()).toBe(remainingNames.length) for (const [index, name] of remainingNames.entries()) { expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1) } expect(await menu.getByRole('separator').count()).toBe(0) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await menu.getByRole('menuitem', { name: 'Copy Session ID', exact: true }).click() + await expect.poll(() => menu.count()).toBe(0) + await page.getByRole('alert').filter({ hasText: 'Session ID copied' }).waitFor() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(sessionId) expect(console.pageErrors).toEqual([]) await assertFixtureInventory(EXPECTED, [ 'bootstrap-rebuild.expected.md', 'enabled.expected.md', 'recovered.expected.md', diff --git a/apps/web/tests/collapse-timing.e2e.ts b/apps/web/tests/collapse-timing.e2e.ts new file mode 100644 index 0000000000..a33867b404 --- /dev/null +++ b/apps/web/tests/collapse-timing.e2e.ts @@ -0,0 +1,191 @@ +/** Recorded work details rendered with independent completion and next-input folding. */ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page, Route } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { deriveReplayScript } from '@deepseek-ai/dsh-llm-replay' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, parseSeedFixture, readPersistedEvents, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, openSettings, saveFailureShot, writeComposerDraft } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/collapse-timing', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +const SCREENSHOTS = 'screenshots/collapse-timing' +const SCREENSHOT_DIR = fileURLToPath(new URL(`../../../.artifacts/${SCREENSHOTS}`, import.meta.url)) +const SEED_ID = 'collapse-timing-web-e2e' +const MODE = webSnapshotMode() +const UI_WAIT = { timeout: 10_000 } +const GOLDENS = [ + 'completion.expected.md', + 'next-input.expected.md', + 'completion-restored.expected.md', + 'input-pending.expected.md', + 'next-message.expected.md', + 'verbose.expected.md', +] + +describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let fixture: string + let prompt: string + let replayDir: string + + beforeAll(async () => { + fixture = await readFile(SEED, 'utf8') + const prompts = fixtureUserPrompts(fixture) + expect(prompts).toHaveLength(1) + prompt = prompts[0]! + const continuation = deriveReplayScript(parseSeedFixture(fixture).events).at(-1) + expect(continuation?.kind).toBe('chunks') + replayDir = await mkdtemp(join(tmpdir(), 'dsh-collapse-timing-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + // Reuse the recorded final reasoning and answer without repeating historical tool-call ids. + await writeFile(replayOverride, JSON.stringify([continuation])) + scaffold = await launchWebScaffold({ + replayFixture: SEED, + replayOverride, + // This rendering borrows a completed turn and replays only its final response as a continuation. + compareReplaySession: false, + }) + await seedSession(scaffold, fixture, SEED_ID) + browser = await chromium.launch({ headless: true }) + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + }) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (replayDir !== undefined) { + await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + } + if (failures.length > 0) throw new AggregateError(failures, 'collapse-timing e2e cleanup failed') + }) + + async function selectPreference(title: string, choice: string): Promise { + await openSettings(page, 'en') + const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + const row = dialog.getByText(title, { exact: true }).locator('../..') + await row.getByRole('button').click() + await page.getByRole('menuitem', { name: choice, exact: true }).click() + await row.getByRole('button', { name: choice, exact: true }).waitFor() + if (title === 'Work details') { + await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, UI_WAIT) + .toMatchObject({ transcriptView: choice.toLowerCase() }) + } + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + } + + async function capture(name: string): Promise { + await page.mouse.move(0, 0) + const aria = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, name), aria, MODE) + } + + async function expectDetails(turn: number, visible: boolean): Promise { + const reasoning = page.locator(`[data-chat-turn="${turn}"] [data-variant="think"]`).last() + expect(await reasoning.count()).toBe(1) + await expect.poll(() => reasoning.isVisible(), UI_WAIT).toBe(visible) + } + + async function expectProcess(turn: number, expanded: boolean): Promise { + const process = page.locator(`[data-turn-process="${turn}"]`) + await expect.poll(() => process.getAttribute('aria-expanded'), UI_WAIT).toBe(String(expanded)) + await expectDetails(turn, expanded) + } + + it('folds at completion, defers the latest process until ordinary input, and keeps Verbose open', async () => { + onTestFailed(async () => { + await mkdir(SCREENSHOT_DIR, { recursive: true }) + const directory = await mkdtemp(join(SCREENSHOT_DIR, 'recorded-')) + await saveFailureShot(page, `${SCREENSHOTS}/${basename(directory)}/failure`) + }) + // Selecting the current client default does not create a saved Host preference. + await selectPreference('Work details', 'Compact') + await selectPreference('Work details', 'Detailed') + const group = page.getByRole('treeitem').first() + await group.waitFor() + if (await group.getAttribute('aria-expanded') === 'false') await group.click() + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + await expectProcess(1, false) + await capture('completion.expected.md') + + await selectPreference('When to Collapse Work Details', 'On next message') + await expectProcess(1, true) + await page.getByRole('button', { name: 'Read a.txt', exact: true }).waitFor() + await capture('next-input.expected.md') + + await selectPreference('When to Collapse Work Details', 'On completion') + await expectProcess(1, false) + await capture('completion-restored.expected.md') + + await selectPreference('When to Collapse Work Details', 'On next message') + await expectProcess(1, true) + const input = page.locator('[data-composer-input]').last() + await writeComposerDraft(page, input, prompt) + await expectProcess(1, true) + const release = Promise.withResolvers() + const promptBlocked = Promise.withResolvers() + const holdPrompt = async (route: Route): Promise => { + promptBlocked.resolve(undefined) + await release.promise + await route.continue() + } + await page.route('**/api/session/prompt', holdPrompt) + try { + await page.getByRole('button', { name: 'Send message', exact: true }).click() + const blockedTimeout = setTimeout(() => { + promptBlocked.reject(new Error('session prompt did not reach the route barrier')) + }, UI_WAIT.timeout) + try { + await promptBlocked.promise + } finally { + clearTimeout(blockedTimeout) + } + await expect.poll(() => page.locator('[data-submission-echo]').count(), UI_WAIT).toBe(1) + await expectProcess(1, false) + expect(await page.locator('[data-turn-process="2"]').count()).toBe(0) + await capture('input-pending.expected.md') + const settled = scaffold.whenTurnSettled() + release.resolve(undefined) + expect(await settled).toBe(SEED_ID) + } finally { + release.resolve(undefined) + await page.unrouteAll({ behavior: 'wait' }) + } + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), UI_WAIT).toBe(2) + await expect.poll(() => page.locator('[data-streaming="true"]').count(), UI_WAIT).toBe(0) + await expectProcess(1, false) + await expectProcess(2, true) + const persisted = await readPersistedEvents(scaffold, SessionId(SEED_ID)) + expect(persisted.filter(event => event.type === 'turn/end').map(event => event.data)).toEqual([ + { turn: 1, reason: { kind: 'completed' } }, + { turn: 2, reason: { kind: 'completed' } }, + ]) + await capture('next-message.expected.md') + + await selectPreference('Work details', 'Verbose') + await selectPreference('When to Collapse Work Details', 'On completion') + await expectDetails(1, true) + await expectDetails(2, true) + expect(await page.locator('[data-turn-process]:enabled').count()).toBe(0) + await capture('verbose.expected.md') + expect(await readFile(SEED, 'utf8')).toBe(fixture) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, GOLDENS) + }) +}) diff --git a/apps/web/tests/composer-focus.e2e.ts b/apps/web/tests/composer-focus.e2e.ts new file mode 100644 index 0000000000..85de7c7346 --- /dev/null +++ b/apps/web/tests/composer-focus.e2e.ts @@ -0,0 +1,132 @@ +/** Browser selection and focus after presses in the composer's unused space. */ +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Page } from 'playwright' +import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/composer-focus', import.meta.url)) +const EXPECTED = join(SNAPSHOT_DIR, 'focus.expected.md') + +/** + * Observe focus and both ends of the browser selection, including its direction. + * @param page - Page containing the active composer. + * @returns The editor's focus, draft and DOM selection. + */ +async function selectionState(page: Page) { + return await page.locator('[data-composer-input]').evaluate((input) => { + const selection = window.getSelection() + return { + focused: document.activeElement === input, + draft: input.textContent, + text: selection?.toString(), + anchor: selection?.anchorOffset, + focus: selection?.focusOffset, + } + }) +} + +/** + * Format observed browser focus and selection for the golden file. + * @param state - Current browser selection. + * @returns Focus, selected text and directed offsets. + */ +function describeSelection(state: Awaited>): string { + return `${state.focused ? 'focused' : 'unfocused'}; selection ${JSON.stringify(state.text)} (${state.anchor} → ${state.focus})` +} + +/** + * Exercise the card's top, inter-row gap, toolbar gap and bottom padding. + * @param page - Page containing an editable composer. + * @returns Stable behavior observations for the expected-output file. + */ +async function exerciseComposer(page: Page): Promise { + const input = page.locator('[data-composer-input][contenteditable="true"]') + await input.click() + await page.keyboard.insertText('alpha beta gamma') + await page.keyboard.press('Home') + for (let index = 0; index < 10; index++) await page.keyboard.press('ArrowRight') + for (let index = 0; index < 4; index++) await page.keyboard.press('Shift+ArrowLeft') + const selected = { focused: true, draft: 'alpha beta gamma', text: 'beta', anchor: 10, focus: 6 } + await expect.poll(() => selectionState(page)).toEqual(selected) + + const points = await page.locator('[data-composer-card]').evaluate((card) => { + const box = card.getBoundingClientRect() + const scroll = card.querySelector('[data-input-scroll]')!.getBoundingClientRect() + const rowElement = card.querySelector('[data-input-scroll]')!.nextElementSibling! + const row = rowElement.getBoundingClientRect() + const tools = rowElement.firstElementChild!.getBoundingClientRect() + const trailing = rowElement.lastElementChild!.getBoundingClientRect() + if (trailing.left <= tools.right) throw new Error('Composer toolbar has no unused gap') + return [ + { name: 'top padding', x: box.x + box.width / 2, y: (box.top + scroll.top) / 2 }, + { name: 'row gap', x: box.x + box.width / 2, y: (scroll.bottom + row.top) / 2 }, + { name: 'toolbar gap', x: (tools.right + trailing.left) / 2, y: row.top + row.height / 2 }, + { name: 'bottom padding', x: box.x + box.width / 2, y: box.bottom - 2 }, + ] + }) + const observations: string[] = [] + for (const point of points) { + await page.mouse.click(point.x, point.y) + await expect.poll(() => selectionState(page)).toEqual(selected) + observations.push(`- ${point.name}: ${describeSelection(await selectionState(page))}`) + } + + const top = points[0]! + const cardBox = await page.locator('[data-composer-card]').boundingBox() + await page.mouse.click(cardBox!.x - 12, top.y) + await expect.poll(async () => (await selectionState(page)).focused).toBe(false) + await page.mouse.click(top.x, top.y) + await expect.poll(() => selectionState(page)).toEqual(selected) + observations.push(`- return from outside: ${describeSelection(await selectionState(page))}`) + + await page.keyboard.insertText('BETA') + await expect.poll(() => input.textContent()).toBe('alpha BETA gamma') + await page.keyboard.press('ArrowLeft') + const caret = { focused: true, draft: 'alpha BETA gamma', text: '', anchor: 9, focus: 9 } + await expect.poll(() => selectionState(page)).toEqual(caret) + await page.mouse.click(top.x, top.y) + await expect.poll(() => selectionState(page)).toEqual(caret) + await page.keyboard.insertText('!') + await expect.poll(() => input.textContent()).toBe('alpha BET!A gamma') + observations.push(`- typing replaces the restored selection and resumes at the restored caret: ${await input.textContent()}`) + return observations +} + +it('restores the previous caret or selection in fresh and existing sessions', async () => { + const scaffold = await launchWebScaffold({}) + const browserPromise = chromium.launch() + onTestFinished(async () => { + try { await (await browserPromise).close() } + finally { await scaffold.close() } + }) + const browser = await browserPromise + const fixture = createChatScrollFixture({ markerPrefix: 'COMPOSER_FOCUS', title: 'Composer focus', turns: 1 }) + await seedSession(scaffold, fixture.log, 'composer-focus-web-e2e') + const page = await newEnglishPage(browser) + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-focus')) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-focus') + const hero = await exerciseComposer(page) + + await page.getByRole('button', { name: 'Search sessions' }).click() + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1)) + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1) + await result.click() + await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor() + const docked = await exerciseComposer(page) + + await compareOrRefreshGolden(EXPECTED, [ + '# Composer whitespace focus', '', '## Fresh session', '', ...hero, + '', '## Existing session', '', ...docked, + ].join('\n'), webSnapshotMode()) + await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md']) + if (webSnapshotMode() !== 'record') expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) +}, 180_000) diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts index 235db04364..3b04ece1e7 100644 --- a/apps/web/tests/cot-translation-paid.e2e.ts +++ b/apps/web/tests/cot-translation-paid.e2e.ts @@ -168,6 +168,7 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr await settings.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await settings.getByRole('button', { name: '插件', exact: true }).click() const panel = settings.locator('[data-plugin-panel]'), card = panel.locator(`[data-plugin-package="${BUNDLE_NAME}"]`) + await panel.getByRole('button', { name: '更多', exact: true }).click() const toggle = card.getByRole('switch'); await toggle.waitFor() expect(await toggle.getAttribute('aria-checked')).toBe('false') await toggle.click() @@ -192,10 +193,10 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr await expect.poll(() => translated.getAttribute('data-translation-state')).toBe('ready') expect(calls).toHaveLength(1) await compareOrRefreshGolden(join(EXPECTED, 'paid-result.expected.md'), - await captureStableAria(page, '[data-cot-translation]', scaffold.workspaceCwd), MODE) - await translated.getByRole('button', { name: '原文', exact: true }).click() + await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), MODE) + await translated.getByRole('button', { name: '查看原文', exact: true }).click() await translated.getByText(ORIGINAL, { exact: true }).waitFor() - await translated.getByRole('button', { name: '译文', exact: true }).click() + await translated.getByRole('button', { name: '查看译文', exact: true }).click() expect(calls).toHaveLength(1) const saved = await readPersistedEvents(scaffold, auditSessionId) expect(saved.filter(event => !isTranslationRecord(event))).toEqual(events) diff --git a/apps/web/tests/cot-translation.e2e.ts b/apps/web/tests/cot-translation.e2e.ts index beb296eeda..9a804fe8b5 100644 --- a/apps/web/tests/cot-translation.e2e.ts +++ b/apps/web/tests/cot-translation.e2e.ts @@ -195,6 +195,7 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen await settings.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await settings.getByRole('button', { name: '插件', exact: true }).click() const panel = settings.locator('[data-plugin-panel]') + await panel.getByRole('button', { name: '更多', exact: true }).click() const card = panel.locator(`[data-plugin-package="${BUNDLE_NAME}"]`) const toggle = card.getByRole('switch') await toggle.waitFor() @@ -219,11 +220,35 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen await expect.poll(() => body.getAttribute('data-translation-state')).toBe('ready') expect(calls).toEqual([{ provider: 'bing', text: ORIGINAL, target: 'zh-Hans', hasAuthorization: false, hasCookie: false }]) await compareOrRefreshGolden(join(EXPECTED, 'bing.expected.md'), - await captureStableAria(page, '[data-cot-translation]', scaffold.workspaceCwd), MODE) - await body.getByRole('button', { name: '原文', exact: true }).click() + await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), MODE) + const markdown = body.locator('[data-markdown-variant="compact"]') + const typography = () => markdown.evaluate((element) => { + const style = getComputedStyle(element) + return [style.fontFamily, style.fontSize, style.fontWeight, style.lineHeight, style.color, style.padding, style.margin] + }) + const translatedTypography = await typography() + const originalAction = body.getByRole('button', { name: '查看原文', exact: true }) + await composer.focus() + await page.mouse.move(0, 0) + await expect.poll(() => originalAction.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + expect(await originalAction.evaluate(element => element.closest('[data-disclosure-header]') === null)).toBe(true) + await originalAction.focus() + await expect.poll(() => originalAction.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await originalAction.press('Enter') await body.getByText(ORIGINAL, { exact: true }).waitFor() expect(await body.getAttribute('data-translation-view')).toBe('original') - await body.getByRole('button', { name: '译文', exact: true }).click() + expect(await typography()).toEqual(translatedTypography) + await page.mouse.move(0, 0) + const translationAction = body.getByRole('button', { name: '查看译文', exact: true }) + const titleBox = await reasoning.getByRole('button', { name: '思考', exact: true }).boundingBox() + const actionBox = await translationAction.boundingBox() + if (titleBox === null || actionBox === null) throw new Error('Reasoning title and translation action must be visible') + expect(actionBox.y).toBeGreaterThanOrEqual(titleBox.y + titleBox.height) + expect(await translationAction.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await translationAction.click() + await composer.focus() + await reasoning.hover() + await expect.poll(() => originalAction.evaluate(element => getComputedStyle(element).opacity)).toBe('1') await body.getByText(BING_TRANSLATION, { exact: true }).waitFor() expect(calls).toHaveLength(1) const savedBing = await readPersistedEvents(scaffold, sessionId) @@ -275,13 +300,13 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen await save.click() await expect.poll(() => save.isDisabled()).toBe(true) await expect.poll(() => body.getAttribute('data-translation-state')).toBe('failed') - await body.getByText('翻译失败,已保留原文', { exact: true }).waitFor() + await body.getByText('翻译暂不可用,已显示原文', { exact: true }).waitFor() await body.getByText(ORIGINAL, { exact: true }).waitFor() expect(await body.getAttribute('data-translation-view')).toBe('original') expect(calls[2]).toEqual({ provider: 'google', text: ORIGINAL, target: 'ja', hasAuthorization: false, hasCookie: false }) expect(calls).toHaveLength(3) await compareOrRefreshGolden(join(EXPECTED, 'failure.expected.md'), - await captureStableAria(page, '[data-cot-translation]', scaffold.workspaceCwd), MODE) + await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), MODE) fail = false await body.getByRole('button', { name: '重试', exact: true }).click() await body.getByText(JAPANESE_TRANSLATION, { exact: true }).waitFor() @@ -315,7 +340,7 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen await expect.poll(() => body.getAttribute('data-translation-state')).toBe('ready') expect(calls).toHaveLength(6) - await panel.getByRole('button', { name: '返回插件列表', exact: true }).click() + await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click() await toggle.click() await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') await body.waitFor({ state: 'detached' }) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 52a3fd09a3..b6ec46c361 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -96,14 +96,18 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) await page.reload({ waitUntil: 'load' }) const input = page.locator('[data-composer-input]').first() - const trigger = page.getByRole('button', { name: /^选择模型.*deepseek-official\/deepseek-v4-flash/ }) + const trigger = page.getByRole('button', { name: '请选择模型', exact: true }) await trigger.waitFor() + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ + provider: 'deepseek-official', model: 'deepseek-v4-flash', + }) await expect.poll(() => input.getAttribute('contenteditable')).toBe('true') await trigger.click() - await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click() await expect.poll(() => input.getAttribute('contenteditable')).toBe('true') - await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-official') + await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ + provider: 'deepseek-official', model: 'deepseek-flash', + }) const settings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') expect(settings).toContain('provider: deepseek-official') expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index d87bf4ce4d..72ebfb69c2 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -487,14 +487,13 @@ describe('web e2e: the composer model switch is the default for later sessions', baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] }, } }) - const seat = page.getByRole('button', { name: new RegExp(`^选择模型.*${ROUTE}/${MODEL}`) }) + const seat = page.getByRole('button', { name: '请选择模型', exact: true }) await seat.waitFor() expect(await box.isEnabled()).toBe(true) expect(scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ provider: ROUTE, model: MODEL }) const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode()) await seat.click() - await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio').first().click() await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/desktop-onboarding.e2e.ts b/apps/web/tests/desktop-onboarding.e2e.ts index e512813ffc..4f0b527885 100644 --- a/apps/web/tests/desktop-onboarding.e2e.ts +++ b/apps/web/tests/desktop-onboarding.e2e.ts @@ -100,6 +100,16 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => async function snapshot(name: string): Promise { await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false') + const hasIllustration = name.startsWith('welcome') || name.startsWith('credit') + if (hasIllustration) { + const decoded = await page.locator('[class*="illustration"] > img').evaluateAll(async elements => + Promise.all(elements.map(async (element) => { + const image = element as HTMLImageElement + await image.decode() + return image.naturalWidth > 0 && image.naturalHeight > 0 + }))) + expect(decoded).toEqual([true, true]) + } const aria = await captureStableAria(page, '[data-desktop-onboarding]', scaffold.workspaceCwd) await mkdir(EXPECTED, { recursive: true }) await compareOrRefreshGolden(join(EXPECTED, `${name}.expected.md`), aria, MODE) @@ -171,7 +181,7 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => expect(action?.width).toBeCloseTo(240, 0) expect(action!.y - (card!.y + card!.height)).toBeCloseTo(40, 0) } - if (name === 'welcome' || name === 'credit') { + if (hasIllustration) { const illustrations = await page.locator('[class*="illustration"] > img:visible').evaluateAll(elements => elements.map((element) => { const image = element as HTMLImageElement const box = image.getBoundingClientRect() diff --git a/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md b/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md index b9967c9722..6c7320a3da 100644 --- a/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md +++ b/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md @@ -1,8 +1,8 @@ -- dialog "确认启用 Auto review(实验)?": - - heading "确认启用 Auto review(实验)?" [level=2] +- dialog "确认启用自动审查(实验)?": + - heading "确认启用自动审查(实验)?" [level=2] - button "关闭" - - paragraph: Auto review 不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查;审查拒绝的调用由你批准或拒绝。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。 + - paragraph: 自动审查不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查;审查拒绝的调用由你批准或拒绝。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。 - checkbox "我已了解这些风险,并愿意继续" - text: 我已了解这些风险,并愿意继续 - button "取消" - - button "启用 Auto review" [disabled] + - button "启用自动审查" [disabled] diff --git a/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md b/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md index 9e942e44b5..688540b20e 100644 --- a/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md +++ b/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md @@ -2,6 +2,4 @@ - menuitem "仅可查看" - menuitem "工作区内修改" - menuitem "完全权限" - - menuitem "Auto review EXP": - - text: Auto review - - superscript: EXP + - menuitem "自动审查 EXP" diff --git a/apps/web/tests/expected/access-confirmation/slash-picker.expected.md b/apps/web/tests/expected/access-confirmation/slash-picker.expected.md index f1b12d28b0..cf54b54669 100644 --- a/apps/web/tests/expected/access-confirmation/slash-picker.expected.md +++ b/apps/web/tests/expected/access-confirmation/slash-picker.expected.md @@ -4,7 +4,7 @@ - option "仅可查看" - option "工作区内修改" [selected] - option "完全权限" - - option "Auto review EXP": - - text: Auto review + - option "自动审查 EXP": + - text: 自动审查 - superscript: EXP - text: 无沙箱运行;每次原生工具调用和 PTC 内层调用前由同一模型进行实验性审查。 diff --git a/apps/web/tests/expected/client-plugin-live/session-actions.expected.md b/apps/web/tests/expected/client-plugin-live/session-actions.expected.md index f3c45cace9..51df861f77 100644 --- a/apps/web/tests/expected/client-plugin-live/session-actions.expected.md +++ b/apps/web/tests/expected/client-plugin-live/session-actions.expected.md @@ -3,6 +3,7 @@ - menuitem "Rename" - menuitem "Fork session" - menuitem "Archive session" + - menuitem "Copy Session ID" - separator - menuitem "Export session" - menuitem "Copy session ID" diff --git a/apps/web/tests/expected/composer-focus/focus.expected.md b/apps/web/tests/expected/composer-focus/focus.expected.md new file mode 100644 index 0000000000..03e7a8dbec --- /dev/null +++ b/apps/web/tests/expected/composer-focus/focus.expected.md @@ -0,0 +1,19 @@ +# Composer whitespace focus + +## Fresh session + +- top padding: focused; selection "beta" (10 → 6) +- row gap: focused; selection "beta" (10 → 6) +- toolbar gap: focused; selection "beta" (10 → 6) +- bottom padding: focused; selection "beta" (10 → 6) +- return from outside: focused; selection "beta" (10 → 6) +- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma + +## Existing session + +- top padding: focused; selection "beta" (10 → 6) +- row gap: focused; selection "beta" (10 → 6) +- toolbar gap: focused; selection "beta" (10 → 6) +- bottom padding: focused; selection "beta" (10 → 6) +- return from outside: focused; selection "beta" (10 → 6) +- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md index 4b5ac3b259..44be4fbdaf 100644 --- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md +++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md @@ -1,8 +1,8 @@ -- button "返回插件列表": 插件列表 +- button "返回 实验性插件": 实验性插件 - button "卸载 思考过程机器翻译" [disabled]: 卸载 - switch "启用 思考过程机器翻译" [checked] - heading "思考过程机器翻译" [level=3] -- text: v0.2.1-alpha.1 实验性 +- text: v0.2.1-alpha.2 实验性 - paragraph: - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" - paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 @@ -39,4 +39,4 @@ - term: 代码来源 - definition: 内置 - term: 当前版本 -- definition: 0.2.1-alpha.1 +- definition: 0.2.1-alpha.2 diff --git a/apps/web/tests/expected/cot-translation-paid/paid-result.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-result.expected.md index 07d4103eff..314344b0e0 100644 --- a/apps/web/tests/expected/cot-translation-paid/paid-result.expected.md +++ b/apps/web/tests/expected/cot-translation-paid/paid-result.expected.md @@ -1,3 +1,3 @@ -- button "译文" [pressed] -- button "原文" +- button "思考" [expanded] +- button "查看原文" - paragraph: 用户要求只回复一个词,我会照做。 diff --git a/apps/web/tests/expected/cot-translation/bing.expected.md b/apps/web/tests/expected/cot-translation/bing.expected.md index 3e7616bb7f..0a9dc0e8a2 100644 --- a/apps/web/tests/expected/cot-translation/bing.expected.md +++ b/apps/web/tests/expected/cot-translation/bing.expected.md @@ -1,3 +1,3 @@ -- button "译文" [pressed] -- button "原文" +- button "思考" [expanded] +- button "查看原文" - paragraph: 用户要求我回复一个单词。 diff --git a/apps/web/tests/expected/cot-translation/failure.expected.md b/apps/web/tests/expected/cot-translation/failure.expected.md index 2b3e993532..b35237dbfa 100644 --- a/apps/web/tests/expected/cot-translation/failure.expected.md +++ b/apps/web/tests/expected/cot-translation/failure.expected.md @@ -1,5 +1,5 @@ -- button "译文" [disabled] -- button "原文" [pressed] -- status: 翻译失败,已保留原文 +- button "思考" [expanded] +- button "查看译文" [disabled] +- status: 翻译暂不可用,已显示原文 - button "重试" - paragraph: The user wants me to reply with a single word. Let me comply. diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md index 48f64db554..14f544b50b 100644 --- a/apps/web/tests/expected/cot-translation/preferences.expected.md +++ b/apps/web/tests/expected/cot-translation/preferences.expected.md @@ -1,8 +1,8 @@ -- button "返回插件列表": 插件列表 +- button "返回 实验性插件": 实验性插件 - button "卸载 思考过程机器翻译" [disabled]: 卸载 - switch "启用 思考过程机器翻译" [checked] - heading "思考过程机器翻译" [level=3] -- text: v0.2.1-alpha.1 实验性 +- text: v0.2.1-alpha.2 实验性 - paragraph: - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" - paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 @@ -36,4 +36,4 @@ - term: 代码来源 - definition: 内置 - term: 当前版本 -- definition: 0.2.1-alpha.1 +- definition: 0.2.1-alpha.2 diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 47440f7c24..271acfa0a2 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -29,7 +29,7 @@ - text: deepseek-flash - textbox "显示名称 1": - /placeholder: 显示名称 - - text: DeepSeek-V41-Flash + - text: DeepSeek-V4.1-Flash - button "模型选项 1" - button "删除模型 1" - textbox "模型 ID 2": diff --git a/apps/web/tests/expected/default-model/unselected.expected.md b/apps/web/tests/expected/default-model/unselected.expected.md index 8845f5196b..b5b50682be 100644 --- a/apps/web/tests/expected/default-model/unselected.expected.md +++ b/apps/web/tests/expected/default-model/unselected.expected.md @@ -1,5 +1,5 @@ - textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" - button "添加文件或调用指令" - button "访问模式,当前:工作区内修改": 工作区内修改 -- button "选择模型,当前 acme-gateway/acme-large": acme-gateway/acme-large +- button "请选择模型" - button "发送消息" [disabled] diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index e0e2e8a50b..b24ebca8d2 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -22,6 +22,8 @@ - button "更多字体设置" - text: 工作步骤展示 选择希望看到多少工具调用细节 - button "标准" + - text: 工作步骤收起时机 选择何时自动收起工作步骤 + - button "回答结束后" - text: 显示代码工作视图 开启后,显示轨迹、本轮代码差异,可选择完整的 Agent 预设切换 - switch "显示代码工作视图" [checked] - text: 快捷键 diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index fff8f92651..801a3d9a5d 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -22,6 +22,8 @@ - button "More font settings" - text: Work details Choose how much detail to show for tool calls - button "Standard" + - text: When to Collapse Work Details Choose when to automatically collapse work details + - button "On completion" - text: Show coding view Shows trajectory, code diffs, and all Agent presets - switch "Show coding view" [checked] - text: Keyboard shortcuts diff --git a/apps/web/tests/expected/menu-material/session-menu.expected.md b/apps/web/tests/expected/menu-material/session-menu.expected.md index 88405f3950..ebb1786386 100644 --- a/apps/web/tests/expected/menu-material/session-menu.expected.md +++ b/apps/web/tests/expected/menu-material/session-menu.expected.md @@ -3,3 +3,4 @@ - menuitem "Rename" - menuitem "Fork session" - menuitem "Archive session" + - menuitem "Copy Session ID" diff --git a/apps/web/tests/expected/navigation-panes/command-wrap.expected.md b/apps/web/tests/expected/navigation-panes/command-wrap.expected.md new file mode 100644 index 0000000000..cafac4c6df --- /dev/null +++ b/apps/web/tests/expected/navigation-panes/command-wrap.expected.md @@ -0,0 +1,14 @@ +[ + { + "colorScheme": "light", + "wrapped": true, + "text": "echo NAVIGATION_OK", + "copiedText": "echo NAVIGATION_OK" + }, + { + "colorScheme": "dark", + "wrapped": true, + "text": "echo NAVIGATION_OK", + "copiedText": "echo NAVIGATION_OK" + } +] diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 803e82b6f0..a4dfae4aa1 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -29,7 +29,7 @@ - text: deepseek-flash - textbox "显示名称 1": - /placeholder: 显示名称 - - text: DeepSeek-V41-Flash + - text: DeepSeek-V4.1-Flash - button "模型选项 1" [expanded] - button "删除模型 1" - text: 上下文窗口 diff --git a/apps/web/tests/expected/onboarding-deepseek-config/empty.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/empty.expected.md new file mode 100644 index 0000000000..c0a161b0e3 --- /dev/null +++ b/apps/web/tests/expected/onboarding-deepseek-config/empty.expected.md @@ -0,0 +1,2 @@ +- group "模型与推理等级": + - status: 暂无可用模型 diff --git a/apps/web/tests/expected/plugin-config/bundle.expected.md b/apps/web/tests/expected/plugin-config/bundle.expected.md index f71e0dad28..2e90c68ac0 100644 --- a/apps/web/tests/expected/plugin-config/bundle.expected.md +++ b/apps/web/tests/expected/plugin-config/bundle.expected.md @@ -1,4 +1,4 @@ -- button "返回插件列表": 插件列表 +- button "返回插件": 插件 - button "夹具操作" - button "卸载 @fixture/live-client": 卸载 - switch "启用 @fixture/live-client" [checked] diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index aee4cb8cdf..37d3a232b4 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -5,75 +5,40 @@ - group "添加插件": - button "添加插件" - button "选择添加插件方式" -- heading "官方" [level=3] -- text: "17" +- heading "内置插件配置" [level=3] - list: - - listitem: - - button "查看 智能体团队": 智能体团队 - - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 - - switch "启用 智能体团队" - - listitem: - - button "查看 自动授权审查": 自动授权审查 - - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - - switch "启用 自动授权审查" - - listitem: - - button "查看 DSH 徽章技能": DSH 徽章技能 - - text: 实验性 提供可发现的 powered-by-DSH 徽章技能。 - - switch "启用 DSH 徽章技能" - - listitem: - - button "查看 思考过程机器翻译": 思考过程机器翻译 - - text: 实验性 机器翻译展开的思考内容;原文会发送给所选服务 - - switch "启用 思考过程机器翻译" - - listitem: - - button "查看 开发者工具": 开发者工具 - - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 - - switch "启用 开发者工具" - - listitem: - - button "查看 Ralph 循环": Ralph 循环 - - text: 实验性 使用新智能体执行用户明确要求的循环工作。 - - switch "启用 Ralph 循环" - - listitem: - - button "查看 会话搜索": 会话搜索 - - text: 实验性 为智能体提供搜索和读取当前工作区历史会话的工具。 - - switch "启用 会话搜索" - - listitem: - - button "查看 随对话更新的标题": 随对话更新的标题 - - text: 实验性 在收到新的用户提示时更新会话标题。 - - switch "启用 随对话更新的标题" - - listitem: - - button "查看 持久终端": 持久终端 - - text: 实验性 让 shell 进程在多次工具调用之间保持运行。 - - switch "启用 持久终端" - - listitem: - - button "查看 Git 工作树": Git 工作树 - - text: 实验性 让智能体创建新的 Git 分支与工作树,并在其中继续工作。 - - switch "启用 Git 工作树" - - listitem: - - button "查看 语音输入": 语音输入 - - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 - - switch "启用 语音输入" - - listitem: - - button "查看 Claude Code 子智能体": Claude Code 子智能体 - - text: 未安装 使用 Claude Code 原生认证和设置委派单个任务。 - - switch "启用 Claude Code 子智能体" - - listitem: - - button "查看 Codex 子智能体": Codex 子智能体 - - text: 未安装 使用 Codex 原生认证和设置委派单个任务。 - - switch "启用 Codex 子智能体" - - listitem: - - button "查看 终端": 终端 - - text: 限制每条命令最多能跑多久、最多输出多少内容。 - - listitem: - - button "查看 Agent 循环": Agent 循环 - - text: 控制 Agent 派发工具调用的方式。 - listitem: - button "查看 子智能体": 子智能体 - text: 设置子智能体的递归层级、数量和模型。 - listitem: - button "查看 网页搜索": 网页搜索 - text: 设置 DeepSeek 的搜索提供方。 -- heading "已安装" [level=3] -- text: "2" + - listitem: + - button "查看 Agent 循环": Agent 循环 + - text: 控制 Agent 派发工具调用的方式。 + - listitem: + - button "查看 终端": 终端 + - text: 限制每条命令最多能跑多久、最多输出多少内容。 +- heading "实验性插件" [level=3] +- button "更多" +- list: + - listitem: + - button "查看 智能体团队": 智能体团队 + - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 + - switch "启用 智能体团队" + - listitem: + - button "查看 语音输入": 语音输入 + - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 + - switch "启用 语音输入" + - listitem: + - button "查看 自动授权审查": 自动授权审查 + - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - switch "启用 自动授权审查" + - listitem: + - button "查看 开发者工具": 开发者工具 + - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 + - switch "启用 开发者工具" +- heading "已安装插件" [level=3] - list: - listitem: - button "查看 @fixture/live-client": "@fixture/live-client" diff --git a/apps/web/tests/expected/plugin-config/subagent.expected.md b/apps/web/tests/expected/plugin-config/subagent.expected.md index 1f324d7161..74bf83d3ea 100644 --- a/apps/web/tests/expected/plugin-config/subagent.expected.md +++ b/apps/web/tests/expected/plugin-config/subagent.expected.md @@ -1,4 +1,4 @@ -- button "返回插件列表": 插件列表 +- button "返回插件": 插件 - heading "子智能体" [level=3] - paragraph: 设置子智能体的递归层级、数量和模型。 - region "运行限制": diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md index 2a86287511..702150c359 100644 --- a/apps/web/tests/expected/plugin-manager/exports.expected.md +++ b/apps/web/tests/expected/plugin-manager/exports.expected.md @@ -1,4 +1,4 @@ -- button "返回插件列表": 插件列表 +- button "返回插件": 插件 - button "卸载 @fixture/bundle": 卸载 - switch "启用 @fixture/bundle" - heading "@fixture/bundle" [level=3] diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 975951d9eb..eab9dd48e1 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -5,75 +5,40 @@ - group "添加插件": - button "添加插件" - button "选择添加插件方式" -- heading "官方" [level=3] -- text: "17" +- heading "内置插件配置" [level=3] - list: - - listitem: - - button "查看 智能体团队": 智能体团队 - - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 - - switch "启用 智能体团队" - - listitem: - - button "查看 自动授权审查": 自动授权审查 - - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - - switch "启用 自动授权审查" - - listitem: - - button "查看 DSH 徽章技能": DSH 徽章技能 - - text: 实验性 提供可发现的 powered-by-DSH 徽章技能。 - - switch "启用 DSH 徽章技能" - - listitem: - - button "查看 思考过程机器翻译": 思考过程机器翻译 - - text: 实验性 机器翻译展开的思考内容;原文会发送给所选服务 - - switch "启用 思考过程机器翻译" - - listitem: - - button "查看 开发者工具": 开发者工具 - - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 - - switch "启用 开发者工具" - - listitem: - - button "查看 Ralph 循环": Ralph 循环 - - text: 实验性 使用新智能体执行用户明确要求的循环工作。 - - switch "启用 Ralph 循环" - - listitem: - - button "查看 会话搜索": 会话搜索 - - text: 实验性 为智能体提供搜索和读取当前工作区历史会话的工具。 - - switch "启用 会话搜索" - - listitem: - - button "查看 随对话更新的标题": 随对话更新的标题 - - text: 实验性 在收到新的用户提示时更新会话标题。 - - switch "启用 随对话更新的标题" - - listitem: - - button "查看 持久终端": 持久终端 - - text: 实验性 让 shell 进程在多次工具调用之间保持运行。 - - switch "启用 持久终端" - - listitem: - - button "查看 Git 工作树": Git 工作树 - - text: 实验性 让智能体创建新的 Git 分支与工作树,并在其中继续工作。 - - switch "启用 Git 工作树" - - listitem: - - button "查看 语音输入": 语音输入 - - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 - - switch "启用 语音输入" - - listitem: - - button "查看 Claude Code 子智能体": Claude Code 子智能体 - - text: 未安装 使用 Claude Code 原生认证和设置委派单个任务。 - - switch "启用 Claude Code 子智能体" - - listitem: - - button "查看 Codex 子智能体": Codex 子智能体 - - text: 未安装 使用 Codex 原生认证和设置委派单个任务。 - - switch "启用 Codex 子智能体" - - listitem: - - button "查看 终端": 终端 - - text: 限制每条命令最多能跑多久、最多输出多少内容。 - - listitem: - - button "查看 Agent 循环": Agent 循环 - - text: 控制 Agent 派发工具调用的方式。 - listitem: - button "查看 子智能体": 子智能体 - text: 设置子智能体的递归层级、数量和模型。 - listitem: - button "查看 网页搜索": 网页搜索 - text: 设置 DeepSeek 的搜索提供方。 -- heading "已安装" [level=3] -- text: "2" + - listitem: + - button "查看 Agent 循环": Agent 循环 + - text: 控制 Agent 派发工具调用的方式。 + - listitem: + - button "查看 终端": 终端 + - text: 限制每条命令最多能跑多久、最多输出多少内容。 +- heading "实验性插件" [level=3] +- button "更多" +- list: + - listitem: + - button "查看 智能体团队": 智能体团队 + - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 + - switch "启用 智能体团队" + - listitem: + - button "查看 语音输入": 语音输入 + - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 + - switch "启用 语音输入" + - listitem: + - button "查看 自动授权审查": 自动授权审查 + - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - switch "启用 自动授权审查" + - listitem: + - button "查看 开发者工具": 开发者工具 + - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 + - switch "启用 开发者工具" +- heading "已安装插件" [level=3] - list: - listitem: - button "查看 @fixture/bundle": "@fixture/bundle" diff --git a/apps/web/tests/expected/plugin-manager/loading.expected.md b/apps/web/tests/expected/plugin-manager/loading.expected.md index 4cf8d9ea23..01f06171a3 100644 --- a/apps/web/tests/expected/plugin-manager/loading.expected.md +++ b/apps/web/tests/expected/plugin-manager/loading.expected.md @@ -7,8 +7,8 @@ - button "选择添加插件方式" [disabled] - status "正在读取插件…" -1680px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px -1000px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px +1680px: 4 rows; page/group headers, rows, icons and text line origins align within 0.1px +1000px: 4 rows; page/group headers, rows, icons and text line origins align within 0.1px Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders Loaded: skeleton removed; header actions enabled Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 33570a25d2..f3dd4a7bc6 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -5,75 +5,40 @@ - group "添加插件": - button "添加插件" - button "选择添加插件方式" -- heading "官方" [level=3] -- text: "17" +- heading "内置插件配置" [level=3] - list: - - listitem: - - button "查看 智能体团队": 智能体团队 - - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 - - switch "启用 智能体团队" - - listitem: - - button "查看 自动授权审查": 自动授权审查 - - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - - switch "启用 自动授权审查" - - listitem: - - button "查看 DSH 徽章技能": DSH 徽章技能 - - text: 实验性 提供可发现的 powered-by-DSH 徽章技能。 - - switch "启用 DSH 徽章技能" - - listitem: - - button "查看 思考过程机器翻译": 思考过程机器翻译 - - text: 实验性 机器翻译展开的思考内容;原文会发送给所选服务 - - switch "启用 思考过程机器翻译" - - listitem: - - button "查看 开发者工具": 开发者工具 - - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 - - switch "启用 开发者工具" - - listitem: - - button "查看 Ralph 循环": Ralph 循环 - - text: 实验性 使用新智能体执行用户明确要求的循环工作。 - - switch "启用 Ralph 循环" - - listitem: - - button "查看 会话搜索": 会话搜索 - - text: 实验性 为智能体提供搜索和读取当前工作区历史会话的工具。 - - switch "启用 会话搜索" - - listitem: - - button "查看 随对话更新的标题": 随对话更新的标题 - - text: 实验性 在收到新的用户提示时更新会话标题。 - - switch "启用 随对话更新的标题" - - listitem: - - button "查看 持久终端": 持久终端 - - text: 实验性 让 shell 进程在多次工具调用之间保持运行。 - - switch "启用 持久终端" - - listitem: - - button "查看 Git 工作树": Git 工作树 - - text: 实验性 让智能体创建新的 Git 分支与工作树,并在其中继续工作。 - - switch "启用 Git 工作树" - - listitem: - - button "查看 语音输入": 语音输入 - - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 - - switch "启用 语音输入" - - listitem: - - button "查看 Claude Code 子智能体": Claude Code 子智能体 - - text: 未安装 使用 Claude Code 原生认证和设置委派单个任务。 - - switch "启用 Claude Code 子智能体" - - listitem: - - button "查看 Codex 子智能体": Codex 子智能体 - - text: 未安装 使用 Codex 原生认证和设置委派单个任务。 - - switch "启用 Codex 子智能体" - - listitem: - - button "查看 终端": 终端 - - text: 限制每条命令最多能跑多久、最多输出多少内容。 - - listitem: - - button "查看 Agent 循环": Agent 循环 - - text: 控制 Agent 派发工具调用的方式。 - listitem: - button "查看 子智能体": 子智能体 - text: 设置子智能体的递归层级、数量和模型。 - listitem: - button "查看 网页搜索": 网页搜索 - text: 设置 DeepSeek 的搜索提供方。 -- heading "已安装" [level=3] -- text: "2" + - listitem: + - button "查看 Agent 循环": Agent 循环 + - text: 控制 Agent 派发工具调用的方式。 + - listitem: + - button "查看 终端": 终端 + - text: 限制每条命令最多能跑多久、最多输出多少内容。 +- heading "实验性插件" [level=3] +- button "更多" +- list: + - listitem: + - button "查看 智能体团队": 智能体团队 + - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 + - switch "启用 智能体团队" + - listitem: + - button "查看 语音输入": 语音输入 + - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 + - switch "启用 语音输入" + - listitem: + - button "查看 自动授权审查": 自动授权审查 + - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - switch "启用 自动授权审查" + - listitem: + - button "查看 开发者工具": 开发者工具 + - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 + - switch "启用 开发者工具" +- heading "已安装插件" [level=3] - list: - listitem: - button "查看 @fixture/bundle": "@fixture/bundle" diff --git a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md index c1658e9fe2..268ad18e27 100644 --- a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md +++ b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md @@ -1,4 +1,4 @@ -- button "返回插件列表": 插件列表 +- button "返回插件": 插件 - button "卸载 @fixture/missing-bundle": 卸载 - switch "启用 @fixture/missing-bundle" [checked] - heading "@fixture/missing-bundle" [level=3] diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md index 1ed42daaa5..75dda78845 100644 --- a/apps/web/tests/expected/plugin-manager/refresh.expected.md +++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md @@ -6,7 +6,7 @@ Focus at 500ms: - tooltip "刷新" failure: pending - button "刷新" [disabled] -{"busy":"true","spinners":1,"cards":20,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} failure: settled - button "刷新" -{"busy":"false","spinners":0,"cards":20,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} +{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} diff --git a/apps/web/tests/expected/seeded-history/blank-reasoning.expected.md b/apps/web/tests/expected/seeded-history/blank-reasoning.expected.md new file mode 100644 index 0000000000..ab493a1aff --- /dev/null +++ b/apps/web/tests/expected/seeded-history/blank-reasoning.expected.md @@ -0,0 +1,4 @@ +- button "Think" [expanded] +- paragraph: The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. + +- Reasoning rows: 1 diff --git a/apps/web/tests/expected/settings-chrome/collapse-timing-en.expected.md b/apps/web/tests/expected/settings-chrome/collapse-timing-en.expected.md new file mode 100644 index 0000000000..f06030ef91 --- /dev/null +++ b/apps/web/tests/expected/settings-chrome/collapse-timing-en.expected.md @@ -0,0 +1,3 @@ +- menu: + - menuitem "On completion" + - menuitem "On next message" diff --git a/apps/web/tests/expected/settings-chrome/collapse-timing-zh.expected.md b/apps/web/tests/expected/settings-chrome/collapse-timing-zh.expected.md new file mode 100644 index 0000000000..e7b47ba72f --- /dev/null +++ b/apps/web/tests/expected/settings-chrome/collapse-timing-zh.expected.md @@ -0,0 +1,3 @@ +- menu: + - menuitem "回答结束后" + - menuitem "下次有新消息时" diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index c59d632773..13cd42f9d7 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -22,6 +22,8 @@ - button "More font settings" - text: Work details Choose how much detail to show for tool calls - button "Detailed" + - text: When to Collapse Work Details Choose when to automatically collapse work details + - button "On completion" - text: Show coding view Shows trajectory, code diffs, and all Agent presets - switch "Show coding view" - text: Keyboard shortcuts diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index c59d632773..13cd42f9d7 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -22,6 +22,8 @@ - button "More font settings" - text: Work details Choose how much detail to show for tool calls - button "Detailed" + - text: When to Collapse Work Details Choose when to automatically collapse work details + - button "On completion" - text: Show coding view Shows trajectory, code diffs, and all Agent presets - switch "Show coding view" - text: Keyboard shortcuts diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index a9705c27f8..df60e07cde 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -22,6 +22,8 @@ - button "更多字体设置" - text: 工作步骤展示 选择希望看到多少工具调用细节 - button "详细" + - text: 工作步骤收起时机 选择何时自动收起工作步骤 + - button "回答结束后" - text: 显示代码工作视图 开启后,显示轨迹、本轮代码差异,可选择完整的 Agent 预设切换 - switch "显示代码工作视图" - text: 快捷键 diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md index bafdc63177..9c455c27da 100644 --- a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md @@ -1,22 +1,24 @@ - list: - listitem: - - button "tool-subagent-control, 已启用": - - strong: tool-subagent-control - - text: Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations + - button "子智能体控制工具, tool-subagent-control, 已启用": + - strong: 子智能体控制工具 + - text: 让 AI 向子智能体发送消息或中断其运行。 + - code: tool-subagent-control - listitem: - button "tool-subagent/list-models, tool-subagent-list-models, 已启用": - strong: tool-subagent/list-models - code: tool-subagent-list-models - listitem: - - button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用": - - strong: tool-subagent-control/list-agents + - button "子智能体列表工具, tool-subagent-list-agents, 已启用": + - strong: 子智能体列表工具 - code: tool-subagent-list-agents - listitem: - - button "tool-subagent, 已启用": - - strong: tool-subagent - - text: Model-facing subagent delegation tool over the ctx.subagents seam + - button "子智能体委派工具, tool-subagent, 已启用": + - strong: 子智能体委派工具 + - text: 让 AI 将任务交给子智能体,并获取运行结果。 + - code: tool-subagent - listitem: - - button "tool-subagent, tool-subagent-fork, 已启用": - - strong: tool-subagent - - text: Model-facing subagent delegation tool over the ctx.subagents seam + - button "子智能体委派工具, tool-subagent-fork, 已启用": + - strong: 子智能体委派工具 + - text: 让 AI 将任务交给子智能体,并获取运行结果。 - code: tool-subagent-fork diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md index 20e096a308..22b5bc2c80 100644 --- a/apps/web/tests/expected/settings-chrome/plugins.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md @@ -1,4 +1,5 @@ - listitem: - - button "tool-subagent, 已启用": - - strong: tool-subagent - - text: Model-facing subagent delegation tool over the ctx.subagents seam + - button "子智能体委派工具, tool-subagent, 已启用": + - strong: 子智能体委派工具 + - text: 让 AI 将任务交给子智能体,并获取运行结果。 + - code: tool-subagent diff --git a/apps/web/tests/expected/shell-command-details/background-long-token.expected.md b/apps/web/tests/expected/shell-command-details/background-long-token.expected.md new file mode 100644 index 0000000000..c9e1765740 --- /dev/null +++ b/apps/web/tests/expected/shell-command-details/background-long-token.expected.md @@ -0,0 +1,3 @@ +- button "Bash Start the diagnostic worker" [expanded] +- text: {{workspace}} node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log --token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +- button "Inspect" diff --git a/apps/web/tests/expected/shell-command-details/background.expected.md b/apps/web/tests/expected/shell-command-details/background.expected.md index ffc21b3098..1b579bfa11 100644 --- a/apps/web/tests/expected/shell-command-details/background.expected.md +++ b/apps/web/tests/expected/shell-command-details/background.expected.md @@ -1,5 +1,3 @@ - button "Bash Start the diagnostic worker" [expanded] -- text: IN -- group "Command arguments": "{ \"command\": \"node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log\", \"description\": \"Start the diagnostic worker\", \"run_in_background\": true }" -- text: OUT started background job bash-1 +- text: {{workspace}} node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log - button "Inspect" diff --git a/apps/web/tests/expected/shell-command-details/foreground-multiline.expected.md b/apps/web/tests/expected/shell-command-details/foreground-multiline.expected.md new file mode 100644 index 0000000000..05fadfea88 --- /dev/null +++ b/apps/web/tests/expected/shell-command-details/foreground-multiline.expected.md @@ -0,0 +1,5 @@ +- button "Bash Start the diagnostic worker" [expanded] +- text: Done {{workspace}} node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log --token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx $ echo done +- button "Copy" +- text: done +- button "Inspect" diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md index 88405f3950..ebb1786386 100644 --- a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md @@ -3,3 +3,4 @@ - menuitem "Rename" - menuitem "Fork session" - menuitem "Archive session" + - menuitem "Copy Session ID" diff --git a/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md b/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md new file mode 100644 index 0000000000..0e4f8f018b --- /dev/null +++ b/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md @@ -0,0 +1,13 @@ +# Before opening the child + +- tree "Subagent sessions": + - treeitem "historical researcher Migration required · not running" [level=1]: + - text: historical researcher Migration required · not running + - button "Open historical researcher in sidebar" + +# After opening the child + +- tree "Subagent sessions": + - treeitem "historical editor Migration required · not running" [level=1]: + - text: historical editor Migration required · not running + - button "Open historical editor in sidebar" diff --git a/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md index bb37e36cfb..cc84fced0a 100644 --- a/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md @@ -1 +1 @@ -You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js index 6a6f8b9777..7736cb2949 100644 --- a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js +++ b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js @@ -89,7 +89,7 @@ window.__ModuleLoader__.load({ ) }) ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () { - // The shipped rows end at archive (400); these follow as one group, + // The shipped rows end at Session ID (450); these follow as one group, // opened by the export row's hairline. yield registerSessionAction('fixture.export-session', 500, 'exportSession', true) yield registerSessionAction('fixture.copy-session-id', 600, 'copySessionId', false) diff --git a/apps/web/tests/katex-fonts.spec.ts b/apps/web/tests/katex-fonts.spec.ts new file mode 100644 index 0000000000..1e7d2f47c5 --- /dev/null +++ b/apps/web/tests/katex-fonts.spec.ts @@ -0,0 +1,75 @@ +import { readFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { transform } from 'lightningcss' +import { build, loadConfigFromFile } from 'vite' +import { expect, it, onTestFinished } from 'vitest' + +const require = createRequire(new URL('../../../packages/client/ui-primitives/package.json', import.meta.url)) +const stylesheet = require.resolve('katex/dist/katex.min.css') + +/** Build a stylesheet with the application's asset plugins without writing artifacts. */ +async function bundleStyle(source: { path: string } | { package: string; css: string }) { + const root = await mkdtemp(join(tmpdir(), 'katex-fonts-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + let input: string + if ('path' in source) input = source.path + else { + input = join(root, 'node_modules', source.package, 'dist', 'katex.min.css') + await mkdir(dirname(input), { recursive: true }) + await writeFile(input, source.css) + await writeFile(join(dirname(input), 'font.woff'), 'fixture font') + } + const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, fileURLToPath(new URL('../vite.config.ts', import.meta.url))) + if (!loaded) throw new Error('Expected the Web build configuration') + // The product isolation check requires the full application's HTML entry. + const plugins = (loaded.config.plugins ?? []).flat().filter(plugin => !(plugin && typeof plugin === 'object' + && 'name' in plugin && plugin.name === 'dsh-product-web-bundle-isolation')) + const result = await build({ + ...loaded.config, configFile: false, plugins, + root, logLevel: 'silent', + build: { write: false, cssMinify: false, assetsInlineLimit: 0, rollupOptions: { input } }, + }) + if ('on' in result) throw new Error('Expected a completed build') + return (Array.isArray(result) ? result : [result]).flatMap(bundle => bundle.output) +} + +/** Normalize layout CSS independently of emitted font URLs and format fallbacks. */ +function layoutCss(css: string): string { + return transform({ + filename: 'layout.css', code: Buffer.from(css), minify: true, + visitor: { Rule: { 'font-face'(rule) { + rule.value.properties = rule.value.properties.filter(property => property.type !== 'source') + return rule + } } }, + }).code.toString() +} + +it('ships all KaTeX WOFF2 faces with identical font bytes and layout styles', async () => { + const output = await bundleStyle({ path: stylesheet }) + const fonts = output.filter(asset => asset.type === 'asset' && /\.(?:woff2?|ttf)$/u.test(asset.fileName)) + expect(fonts).toHaveLength(20) + for (const font of fonts) { + expect(font.fileName).toMatch(/\.woff2$/u) + if (font.type !== 'asset') throw new Error('Expected a font asset') + const name = font.names[0]! + expect(Buffer.from(font.source)).toEqual(await readFile(join(dirname(stylesheet), 'fonts', name))) + } + const css = output.find(asset => asset.type === 'asset' && asset.fileName.endsWith('.css')) + if (css?.type !== 'asset') throw new Error('Expected a stylesheet asset') + const text = Buffer.from(css.source).toString() + expect(text).not.toMatch(/\.(?:woff|ttf)["')]/u) + expect(layoutCss(text)).toBe(layoutCss(await readFile(stylesheet, 'utf8'))) +}) + +it.each(['src: url(font.woff) format("woff");', ''])('rejects a KaTeX face without WOFF2 (src=%s)', async (source) => { + await expect(bundleStyle({ package: 'katex', css: `@font-face { font-family: KaTeX_Main; ${source} }` })) + .rejects.toThrow('KaTeX font face requires a WOFF2 source') +}) + +it('preserves older font formats in unrelated stylesheets', async () => { + const output = await bundleStyle({ package: 'other', css: '@font-face { font-family: Other; src: url(font.woff) format("woff"); }' }) + expect(output.some(asset => asset.type === 'asset' && asset.fileName.endsWith('.woff'))).toBe(true) +}) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 4f9fba2f43..a9226b7fb7 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -399,14 +399,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const processWasOpen = await process.getAttribute('aria-expanded') === 'true' try { await expandOwningTurnProcess(page, thinkRow) - const collapsedHeader = thinkRow.locator('[data-disclosure-row]').first() + const collapsedHeader = thinkRow.locator('[data-disclosure-header]').first() await collapsedHeader.waitFor({ timeout: 10_000 }) // Collapsed, the rule's `data-open` gate is absent and the header stays in - // flow. It is `relative` here — the row is the sweep-glare overlay anchor - // — so the assertion is the absence of `sticky`, not a specific value. + // flow, so assert the absence of `sticky`, not a specific value. expect(await collapsedHeader.evaluate(element => getComputedStyle(element).position)).not.toBe('sticky') - await collapsedHeader.click() - const openHeader = page.locator('[data-variant="think"] [data-open] [data-disclosure-row]').first() + await collapsedHeader.locator('[data-disclosure-row]').click() + const openHeader = page.locator('[data-variant="think"] [data-open] [data-disclosure-header]').first() await openHeader.waitFor({ timeout: 10_000 }) const openStyle = await openHeader.evaluate((element) => { const style = getComputedStyle(element) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 2af1c89b66..1eedb317fd 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -41,6 +41,7 @@ const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md') const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md') const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md') const RETRY_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'retry-expanded.expected.md') +const RETRY_REQUEST_EXPECTED = join(SNAPSHOT_DIR, 'retry-request.expected.md') const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md') const MODE = webSnapshotMode() const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid' @@ -342,11 +343,22 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // sourced from the recording, never copied into a committed sidecar. { at: 1, entry: derived[0]! }, ], - })) + }), { + mode: 'normal', maxRetries: 5, retryableCodes: ['SERVER'], + backoff: { initialDelayMs: 25, maxDelayMs: 25, jitterRatio: 0 }, + }) onTestFailed(() => saveFailureShot(page, 'web-e2e-retry')) const releaseFailure = Promise.withResolvers() + const retryReady = Promise.withResolvers() + const releaseRetry = Promise.withResolvers() + const requestStates: string[] = [] + let attempts = 0 // Retirement must follow a visible partial, not race the first browser paint. const stopHolding = scaffold!.ctx.on('llm/stream', async function* (_request, next) { + if (++attempts === 2) { + retryReady.resolve(undefined) + await releaseRetry.promise + } for await (const chunk of next()) { yield chunk if (chunk.type === 'text-delta' && chunk.text === RETRY_PARTIAL) await releaseFailure.promise @@ -356,9 +368,20 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { const { settled } = await sendPrompt(60_000) await page.getByText(RETRY_PARTIAL, { exact: true }).waitFor({ timeout: 15_000 }) releaseFailure.resolve(undefined) + await retryReady.promise + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true }).click() + const overview = page.getByRole('tabpanel').locator('dl').first() + await overview.getByText('Pending', { exact: true }).waitFor() + await overview.getByText('Last attempt error', { exact: true }).waitFor() + expect(await overview.getByText('Error', { exact: true }).count()).toBe(0) + requestStates.push('# Retrying request', await overview.ariaSnapshot()) + await page.getByRole('tab', { name: 'Chat', exact: true }).click() + releaseRetry.resolve(undefined) await settled } finally { releaseFailure.resolve(undefined) + releaseRetry.resolve(undefined) stopHolding() } expect(turnEndReasons(sessionEvents).at(-1)).toBe('completed') @@ -379,6 +402,14 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { scaffold!.workspaceCwd, ) await compareOrRefreshGolden(RETRY_EXPANDED_EXPECTED, expanded, MODE) + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true }).click() + const overview = page.getByRole('tabpanel').locator('dl').first() + await overview.getByText('Completed', { exact: true }).waitFor() + await overview.getByText('Last attempt error', { exact: true }).waitFor() + expect(await overview.getByText('Error', { exact: true }).count()).toBe(0) + requestStates.push('# Recovered request', await overview.ariaSnapshot()) + await compareOrRefreshGolden(RETRY_REQUEST_EXPECTED, requestStates.join('\n\n'), MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 120_000) @@ -420,7 +451,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md', 'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md', - 'retry.expected.md', 'retry-expanded.expected.md', 'retry-exhausted.expected.md', + 'retry.expected.md', 'retry-expanded.expected.md', 'retry-request.expected.md', 'retry-exhausted.expected.md', ]) }) }) diff --git a/apps/web/tests/markdown-file-links.e2e.ts b/apps/web/tests/markdown-file-links.e2e.ts index e7acd74152..3b9cb9879b 100644 --- a/apps/web/tests/markdown-file-links.e2e.ts +++ b/apps/web/tests/markdown-file-links.e2e.ts @@ -1,18 +1,66 @@ /** Cold-replayed Markdown references open Session files through the shipped Web composition. */ import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import type {} from '@deepseek-ai/dsh-session-title' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/markdown-file-links', import.meta.url)) const MODE = webSnapshotMode() +/** Build live test input with a parent-path link and a read result naming the same Host file. */ +function parentPathFixture(path: string): string { + const session = Session.create(SessionId('parent-file-links-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Read the parent-path file and link lines 24 and 30.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { title: 'Parent file links', messageSeqs: [user.seq], source: { kind: 'fallback' } }) + session.append('step/start', { turn: 1, step: 1 }) + const callId = ToolCallId('parent-file-read') + const args = JSON.stringify({ file_path: path, offset: 24, limit: 7 }) + session.append('assistant/message', { + turn: 1, step: 1, stream: [], + message: createAssistantMessage({ + content: [{ type: 'tool-call', id: callId, name: 'read', arguments: args }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + const call = session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: args }) + session.append('tool/result', { + turn: 1, step: 1, meta: { path: '{{cwd}}/parent.txt' }, + message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'parent line 24\n' }], isError: false }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + session.append('assistant/message', { + turn: 1, step: 2, stream: [], + message: createAssistantMessage({ + content: [{ type: 'text', text: `[Parent file:24–30](<${path}#L24-L30>) · [Parent file:30](<${path}#L30>)` }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [ + JSON.stringify({ + type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, + cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, + }), + ...session.snapshotEvents().map(event => JSON.stringify(event)), + '', + ].join('\n') +} + describe('web e2e: Markdown file links', () => { let scaffold: WebScaffold let browser: Browser @@ -78,4 +126,55 @@ describe('web e2e: Markdown file links', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'links.expected.md', 'preview.expected.md']) }) + + it('opens parent-path prose and read-card links while retaining each address across reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-parent-file-links')) + // Return through the parent into this owned temp root; no shared parent file is created. + const path = `../${basename(scaffold.workspaceCwd)}/parent.txt` + await writeFile(join(scaffold.workspaceCwd, 'parent.txt'), + Array.from({ length: 60 }, (_, i) => `parent line ${i + 1}\n`).join('')) + const id = await seedSession(scaffold, parentPathFixture(path), 'parent-file-links') + await page.reload({ waitUntil: 'load' }) + const workspace = page.getByRole('treeitem').first() + await workspace.waitFor() + if (await workspace.getAttribute('aria-expanded') !== 'true') await workspace.click() + await page.locator(`[data-row-key="session:${id}"]`).click() + + const source = page.getByRole('button', { name: 'Parent file:24–30', exact: true }) + await source.click() + const column = page.locator('[data-rightbar-col]') + const stored = () => page.evaluate(key => localStorage.getItem(key), `dsh.sidebar-right.v1.${id}`) + const address = sessionFileAddress(id, path) + await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('parent line 24\n') + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(scaffold.workspaceCwd, 'parent.txt')) + await expect.poll(stored).toContain(address) + const tabs = column.locator('[data-dockkit-tab-title]') + expect(await tabs.allTextContents()).toEqual(['parent.txt']) + + const later = page.getByRole('button', { name: 'Parent file:30', exact: true }) + await later.focus() + await later.press('Enter') + await expect.poll(() => column.locator('[data-textpreview-target="30"]').textContent()).toBe('parent line 30\n') + expect(await tabs.allTextContents()).toEqual(['parent.txt']) + + await page.reload({ waitUntil: 'load' }) + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(scaffold.workspaceCwd, 'parent.txt')) + await expect.poll(stored).toContain(address) + await source.click() + await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('parent line 24\n') + expect(await tabs.allTextContents()).toEqual(['parent.txt']) + + const readCardLink = page.locator('[data-tool="read"] button').filter({ hasText: 'parent.txt' }) + await expandOwningTurnProcess(page, readCardLink) + await readCardLink.click() + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(scaffold.workspaceCwd, 'parent.txt')) + await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('parent line 24\n') + // The Host-reported path and the unresolved prose path are distinct resource identities. + expect(await tabs.allTextContents()).toEqual(['parent.txt', 'parent.txt']) + await expect.poll(stored).toContain(sessionFileAddress(id, 'parent.txt')) + await source.click() + expect(await tabs.allTextContents()).toEqual(['parent.txt', 'parent.txt']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) }) diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index 5e7d11d7fd..d7fa2b8873 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -122,6 +122,13 @@ describe('web e2e: settled Markdown math rendering', () => { await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6) await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) expect(await page.locator('.katex-error').count()).toBe(0) + const fonts = await page.evaluate(async () => { + const faces = [...document.fonts].filter(face => face.family.includes('KaTeX_')) + await Promise.all(faces.map(face => face.load())) + return faces.map(face => face.status) + }) + expect(fonts).toHaveLength(20) + expect(fonts.every(status => status === 'loaded')).toBe(true) await expect.poll( () => page.getByText('1 turns 1 steps', { exact: false }).count(), { timeout: 10_000 }, diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index c8b9c35b55..2254c9dafe 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -3,16 +3,17 @@ // calls) and pins the settled conversation aria after the footers are // focus-revealed — the surface package jsdom tests cannot substitute for // (docs/testing.md snapshot rule). -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import { zstdCompressSync } from 'node:zlib' +import type { Browser, Page, Request as BrowserRequest } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, parseSeedFixture, realizeSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { openSettings, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts' @@ -22,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actio const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md') +const UNOPENED_FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork-unopened.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' @@ -192,7 +194,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { let unpinBrowserClock: (() => void) | undefined beforeAll(async () => { - scaffold = await launchWebScaffold({}) + scaffold = await launchWebScaffold() + await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/non-migrating-session-reads', import.meta.url)), { recursive: true }) const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') @@ -222,7 +225,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { }) it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) + onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) await groupRow.click() @@ -423,9 +426,83 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await compareOrRefreshGolden(FORK_EXPECTED, tree, MODE) }) + it.skipIf(MODE === 'record')('forks an unopened current-format Session from its sidebar row', async () => { + onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/current-cold-fork')) + const id = SessionId('message-actions-current-cold') + await seedSession(scaffold, completedTailFixture(await readFile(SEED, 'utf8')), id) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + const row = page.locator(`[data-row-key="session:${id}"]`) + await row.waitFor() + acknowledgeReloadConnectionLoss(tripwire, warningStart) + expect(scaffold.ctx.agents.get(id)).toBeUndefined() + await row.hover() + await row.locator('button[aria-label^="Session actions for "]').click() + await page.getByRole('menuitem', { name: 'Fork session', exact: true }).click() + await expect.poll(() => scaffold.ctx.agents.list().some(agent => agent.session.header.parentSession === id)).toBe(true) + expect(scaffold.ctx.agents.get(id)).toBeUndefined() + expect(await row.getAttribute('aria-selected')).not.toBe('true') + }) + + it.skipIf(MODE === 'record')('guides only a migration-required sidebar fork to opening its source', async () => { + onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/migration-required-fork')) + const id = SessionId('message-actions-needs-migration') + const [header, ...events] = realizeSeedFixture(scaffold, await readFile(SEED, 'utf8'), id).trimEnd().split('\n') + .map((line): Record => JSON.parse(line) as Record) + expect(header?.version).toBe(3) + const projects = (await readdir(scaffold.persistenceRoot, { withFileTypes: true })).filter(entry => entry.isDirectory()) + expect(projects).toHaveLength(1) + const directory = join(scaffold.persistenceRoot, projects[0]!.name, id) + const predecessor = join(directory, 'session.v3.jsonl.zstd') + const createdAt = WEB_FIXTURE_TIME - 90_000 + const rows = [{ ...header, id, cwd: scaffold.workspaceCwd, createdAt }, + ...events.map((event, seq) => ({ ...event, seq, time: createdAt + seq + 1 }))] + const source = Buffer.concat(rows.map(row => zstdCompressSync(Buffer.from(`${JSON.stringify(row)}\n`)))) + await mkdir(directory, { recursive: true }) + await writeFile(predecessor, source) + await expect(scaffold.ctx.sessionPersistence.stat(id)).resolves.toMatchObject({ formatStatus: 'migration-required' }) + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.getByRole('button', { name: /^Show \d+ more sessions$/ }).click() + const row = page.locator(`[data-row-key="session:${id}"]`) + await row.waitFor() + acknowledgeReloadConnectionLoss(tripwire, warningStart) + const forkRequests: string[] = [] + const trackFork = (request: BrowserRequest): void => { + const path = new URL(request.url()).pathname + if (path === '/api/session/fork') forkRequests.push(path) + } + page.on('request', trackFork) + try { + await row.hover() + await row.locator('button[aria-label^="Session actions for "]').click() + await page.getByRole('menuitem', { name: 'Fork session', exact: true }).click() + const notice = page.getByRole('alert').filter({ hasText: 'Open the original session to complete migration before creating a branch.' }) + await notice.waitFor() + expect(await page.getByRole('menuitem', { name: 'Fork session', exact: true }).count()).toBe(0) + expect(forkRequests).toEqual(['/api/session/fork']) + expect(scaffold.ctx.agents.get(id)).toBeUndefined() + expect(await readdir(directory)).toEqual(['session.v3.jsonl.zstd']) + expect(await readFile(predecessor)).toEqual(source) + await compareOrRefreshGolden(UNOPENED_FORK_EXPECTED, + await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd), MODE) + await notice.getByRole('button', { name: 'Open original session', exact: true }).click() + await expect.poll(() => row.getAttribute('aria-selected')).toBe('true') + await expect.poll(() => scaffold.ctx.agents.get(id)).toBeDefined() + await expect(scaffold.ctx.sessionPersistence.stat(id)).resolves.toMatchObject({ formatStatus: 'current' }) + expect(await readdir(directory)).toContain(`session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`) + expect(await readFile(predecessor)).toEqual(source) + expect(forkRequests).toEqual(['/api/session/fork']) + expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === id)).toEqual([]) + } finally { + page.off('request', trackFork) + } + }) + it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['compact.expected.md', 'fork.expected.md', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['compact.expected.md', 'fork.expected.md', 'fork-unopened.expected.md', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d121cbf579..75202aea47 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -20,7 +20,7 @@ import { launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' -import { expectSelectableTerminalCommand } from './terminal-command-browser.ts' +import { expectWrappedTerminalCommand } from './terminal-command-browser.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl') @@ -29,6 +29,7 @@ const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md') const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url)) +const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md') const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' @@ -472,7 +473,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click() const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first() await card.waitFor({ timeout: 15_000 }) - await expectSelectableTerminalCommand(page, card, 'echo NAVIGATION_OK', 300) + const commandWrap = await expectWrappedTerminalCommand(page, card, 'echo NAVIGATION_OK', 300) + await compareOrRefreshGolden(COMMAND_WRAP_EXPECTED, JSON.stringify(commandWrap, null, 2), MODE) // Real layout, not jsdom's stub (which computes no geometry at all): // squeeze the output pane below its content width and the line must keep // its single row and overflow sideways instead of folding. Soft-wrapping @@ -547,7 +549,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { - await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md']) + await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md']) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', 'timing.expected.md', diff --git a/apps/web/tests/notice-wake-tail.e2e.ts b/apps/web/tests/notice-wake-tail.e2e.ts new file mode 100644 index 0000000000..884adcb9d3 --- /dev/null +++ b/apps/web/tests/notice-wake-tail.e2e.ts @@ -0,0 +1,218 @@ +/** + * A Turn this Client did not submit (a subagent-settled notice) wakes while the + * reader follows the tail of a long Turn under "On next message" folding. The + * notice Turn belongs to the long Turn's exchange, so the long Turn stays + * expanded through the host's pre-Step gap and after the notice arrives, and a + * reader who was following sees the notice and its reply at the tail. + */ +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SubagentSettledMessageSource } from '@deepseek-ai/dsh-subagent' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +/** Settled Turns before the long one, so every work-details mode overflows the viewport. */ +const HISTORY_TURNS = 3 +const LONG_TURN = HISTORY_TURNS + 1 +const NOTICE_TURN = HISTORY_TURNS + 2 +/** Tool Steps in the finished Turn; their folded height must exceed the notice Turn's height. */ +const TOOL_STEPS = 12 +const OUTPUT_LINES = 40 +/** The recorded host gap between the waking claim and Step 1, spent assembling the system prompt. */ +const PRE_STEP_GAP_MS = 187 +const REPLY = 'NOTICE_WAKE_REPLY' + +interface Tail { + /** Height of the room reserved below the flow. */ + readonly spacer: number + readonly noticeVisible: boolean + readonly replyVisible: boolean + /** Whether the long Turn's process disclosure reports itself expanded. */ + readonly longTurnOpen: boolean +} + +function readTail(page: Page): Promise { + return page.evaluate(([reply, longTurn]) => { + const host = document.querySelector('[data-conversation-scroll]') + if (host === null) throw new Error('conversation scrollport is absent') + const viewTop = host.getBoundingClientRect().top + const viewBottom = host.querySelector('[data-composer-seat]')?.getBoundingClientRect().top + ?? host.getBoundingClientRect().bottom + const inView = (element: Element): boolean => { + const rect = element.getBoundingClientRect() + return rect.height > 0.5 && rect.bottom > viewTop && rect.top < viewBottom + } + const spacer = host.querySelector('[data-chat-turn-spacer]') + return { + spacer: spacer === null ? 0 : Number.parseFloat(spacer.style.height) || 0, + noticeVisible: [...host.querySelectorAll('[data-turn-trigger]')].some(inView), + replyVisible: [...host.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .some(row => (row.textContent ?? '').includes(reply) && inView(row)), + longTurnOpen: host.querySelector(`[data-turn-process="${longTurn}"]`)?.getAttribute('aria-expanded') === 'true', + } + }, [REPLY, String(LONG_TURN)] as const) +} + +describe('web e2e: notice Turn after a long Detailed Turn', () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }) + }, 60_000) + + afterAll(async () => { + await browser?.close() + }) + + it.each(['detailed', 'standard'] as const)('keeps the new tail visible in %s work details', async (view) => { + const scaffold: WebScaffold = await launchWebScaffold({}) + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + try { + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await scaffold.ctx.settings.update('ui-chat', { transcriptView: view, collapseTiming: 'next-input' }) + const agent = scaffold.ctx.agents.list().at(-1) + if (agent === undefined) throw new Error('connected workspace created no Agent') + const session = agent.session + const setRunning = (running: boolean): void => { scaffold.ctx.emit('api-session/status', agent.id, running) } + + for (let turn = 1; turn <= HISTORY_TURNS; turn += 1) { + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `Earlier question ${String(turn)}.` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('assistant/message', { + stream: [], + turn, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `Earlier answer ${String(turn)}. ${'This settled reply wraps across several lines of prose. '.repeat(24)}` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + // A long running Turn streams Step by Step while the reader follows the tail. + setRunning(true) + session.append('turn/start', { turn: LONG_TURN }) + session.append('step/start', { turn: LONG_TURN, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Run the long diagnostic.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + for (let step = 1; step <= TOOL_STEPS; step += 1) { + const callId = ToolCallId(`notice-wake-${String(step)}`) + const args = JSON.stringify({ command: `diagnose ${String(step)}`, description: `Diagnostic ${String(step)}` }) + session.append('assistant/message', { + stream: [], + turn: LONG_TURN, + step, + message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: `Checking diagnostic ${String(step)}.` }, + { type: 'tool-call', id: callId, name: 'bash', arguments: args }, + ], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + const call = session.append('tool/call', { turn: LONG_TURN, step, callId, name: 'bash', arguments: args }) + session.append('tool/result', { + turn: LONG_TURN, + step, + message: createToolResultMessage({ + callId, + content: [{ + type: 'text', + text: Array.from({ length: OUTPUT_LINES }, (_, line) => `diagnostic ${String(step)} line ${String(line + 1)}`).join('\n'), + }], + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + session.append('step/end', { turn: LONG_TURN, step }) + session.append('step/start', { turn: LONG_TURN, step: step + 1 }) + await scaffold.ctx.sessions.flush(session) + await page.locator(`[data-chat-node-key$="${callId}"]`).waitFor({ state: 'attached', timeout: 15_000 }) + } + session.append('assistant/message', { + stream: [], + turn: LONG_TURN, + step: TOOL_STEPS + 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'All diagnostics finished.' }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: LONG_TURN, step: TOOL_STEPS + 1 }) + await scaffold.ctx.sessions.flush(session) + await page.getByText('All diagnostics finished.').waitFor({ timeout: 30_000 }) + await page.evaluate(() => { + const host = document.querySelector('[data-conversation-scroll]') + if (host !== null) host.scrollTop = host.scrollHeight + }) + await page.locator('[data-chat-following-tail]').waitFor({ timeout: 10_000 }) + + // The Turn ends; 15 ms later (the recorded production gap) a settled subagent wakes the next Turn. + session.append('turn/end', { turn: LONG_TURN, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(session) + setRunning(false) + await page.waitForTimeout(15) + const source: SubagentSettledMessageSource = { + kind: 'subagent-settled', + form: 'notice', + summary: 'Background subagent finished.', + senderSessionId: SessionId('notice-wake-child'), + } + const notice = createUserMessage({ + content: [{ type: 'text', text: 'Background subagent notice-wake-child finished.' }], + source, + }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }) + session.append('turn/start', { turn: NOTICE_TURN }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) + setRunning(true) + await scaffold.ctx.sessions.flush(session) + // The browser has applied the notice Turn's start before the gap is held and sampled. + await page.locator(`[data-chat-turn="${String(NOTICE_TURN)}"]`).first().waitFor({ state: 'attached', timeout: 15_000 }) + await page.waitForTimeout(PRE_STEP_GAP_MS) + const gap = await readTail(page) + session.append('step/start', { turn: NOTICE_TURN, step: 1 }) + session.append('user/message', notice, { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(session) + await page.locator('[data-turn-trigger]').first().waitFor({ state: 'attached', timeout: 15_000 }) + session.append('assistant/message', { + stream: [], + turn: NOTICE_TURN, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `${REPLY} the subagent result is acknowledged.` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(session) + + expect(gap).toMatchObject({ longTurnOpen: true, spacer: 0 }) + const settled = { noticeVisible: true, replyVisible: true, longTurnOpen: true, spacer: 0 } + await expect.poll(() => readTail(page), { timeout: 15_000 }).toMatchObject(settled) + // The settled tail survives later frames instead of passing through on the way to a fold. + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) + expect(await readTail(page)).toMatchObject(settled) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } finally { + await page.context().close() + await scaffold.close() + } + }, 180_000) +}) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index ff5f239a8c..c6f965d806 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' +import { credentialRef } from '@deepseek-ai/dsh-credentials' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -208,7 +209,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() await settings.getByText('自定义设置').click() expect(await settings.getByLabel('模型 ID 1').inputValue()).toBe('deepseek-flash') - expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V41-Flash') + expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V4.1-Flash') expect(await settings.getByLabel('模型 ID 2').inputValue()).toBe('deepseek-v4-pro') expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(2) await settings.getByRole('button', { name: '模型选项 1' }).click() @@ -278,20 +279,37 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup // trigger — on the page; the scaffold boots without one. await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e', false) - const modelTrigger = page.getByRole('button', { name: /^选择模型.*deepseek-official\// }) + const modelTrigger = page.getByRole('button', { name: '请选择模型', exact: true }) await modelTrigger.waitFor({ timeout: 10_000 }) await modelTrigger.click() - await page.getByRole('menuitem', { name: /模型/ }).click() expect(await page.getByText('Configured Flash', { exact: true }).count()).toBe(0) await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 }) expect(tripwire.warnings).toEqual([]) expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('shows an empty model list after removing the API key without replacing the saved default', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-model-catalog-empty')) + await page.keyboard.press('Escape') + const selection = scaffold.ctx.agentDefaultModel.currentSelection() + await scaffold.ctx.credentials.unset(credentialRef('DEEPSEEK_API_KEY')) + const seat = page.getByRole('button', { name: '请选择模型', exact: true }) + await seat.waitFor() + await seat.click() + const picker = page.getByRole('group', { name: '模型与推理等级' }) + await picker.getByText('暂无可用模型', { exact: true }).waitFor() + expect(await picker.getByRole('menuitemradio').count()).toBe(0) + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual(selection) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'empty.expected.md'), await picker.ariaSnapshot(), MODE) + await seat.press('Escape') + await picker.waitFor({ state: 'hidden' }) + expect(tripwire.pageErrors).toEqual([]) + }) + it('keeps the fixture inventory closed', async () => { await assertFixtureInventory( SNAPSHOT_DIR, - ['welcome.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md'], + ['welcome.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md', 'empty.expected.md'], ) }) }) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index ea2a30d938..fd8217a2d8 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -7,12 +7,14 @@ // Replay is deterministic: the plan content arrives from replayed chunks, the // review wait is real, and the approve click is the test's own gesture (the // turn cannot complete without it, in record and replay alike). -import { readFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, ConsoleMessage, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { @@ -390,8 +392,12 @@ describe.skipIf(MODE === 'record')('web e2e: pending plan review across Sidebar describe('web e2e: dismissed plan history', () => { it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => { - // Replay supplies a finite continuation; the real question rejection and retained document are the assertions. - const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false, paceMs: 15 }) + // A dismissed review concludes the turn, so only the call presenting the plan replays. + const replayRoot = await mkdtemp(join(tmpdir(), 'dsh-plan-review-dismiss-replay-')) + const replayOverride = join(replayRoot, 'replay.override.json') + const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8'))) + await writeFile(replayOverride, JSON.stringify(script.slice(0, 1))) + const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride, compareReplaySession: false, paceMs: 15 }) let browser: Browser | undefined const events: SessionEvent[] = [] scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) }) @@ -417,9 +423,10 @@ describe('web e2e: dismissed plan history', () => { expect(call).toBeDefined() const results = events.filter(event => event.type === 'tool/result') const result = results.find(event => event.data.message.source.callId === call?.data.callId) - expect(result?.data.message).toMatchObject({ role: 'tool', toolCallId: call?.data.callId, isError: true }) + expect(result?.data.message).toMatchObject({ role: 'tool', toolCallId: call?.data.callId, isError: false }) expect(JSON.stringify(result)).toContain('dismissed the plan review') expect(results.some(event => JSON.stringify(event).includes('Plan approved'))).toBe(false) + expect(events.filter(event => event.type === 'assistant/message')).toHaveLength(1) const modes = events.filter(event => event.type === 'plan/mode') expect(modes).toHaveLength(1) expect(modes[0]).toMatchObject({ data: { active: true } }) @@ -443,6 +450,7 @@ describe('web e2e: dismissed plan history', () => { } finally { await browser?.close() await scaffold.close() + await rm(replayRoot, { recursive: true, force: true }) } }) }) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 3afec1b745..21eddc0fee 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -68,7 +68,7 @@ describe('web e2e: plugin configuration pages', () => { while (await panel.getByRole('button', { name: /^返回/ }).count() > 0) { await panel.getByRole('button', { name: /^返回/ }).first().click() } - await panel.getByRole('heading', { name: '官方', exact: true }).waitFor({ timeout: 20_000 }) + await panel.getByRole('heading', { name: '内置插件配置', exact: true }).waitFor({ timeout: 20_000 }) return panel } @@ -83,24 +83,52 @@ describe('web e2e: plugin configuration pages', () => { return readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8').catch(() => '') } - it('lists one official page per exposed host-plane namespace after the official bundles', async () => { + it('keeps configuration pages first and opens the full experimental plugin list separately', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards')) const panel = await openPlugins() // Every page the shipped web composition exposes: the shell executor, the - // agent loop, subagent selection, and the DeepSeek search provider, after - // the shipped optional bundles and offline on-demand catalog. + // agent loop, subagent selection, and the DeepSeek search provider. await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 }) - const official = panel.locator('[data-plugin-group="official"]') - expect(await official.locator('[data-plugin-package]').count()).toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length) - expect(await official.locator('[data-plugin-item]').count()).toBe(4) - for (const title of ['终端', 'Agent 循环', '子智能体', '网页搜索']) { - expect(await official.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1) + const basic = panel.locator('[data-plugin-group="basic"]') + expect(await basic.locator('[data-plugin-item]').count()).toBe(4) + expect(await basic.locator(':scope > ul > li').evaluateAll(cards => cards.map(card => card.getAttribute('data-plugin-item')))) + .toEqual(['subagent', 'web-search', 'agent-loop', 'shell']) + for (const title of ['子智能体', '网页搜索', 'Agent 循环', '终端']) { + expect(await basic.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1) } // A card carries the one-liner; the fields wait for the page. - expect(await official.getByText('限制每条命令最多能跑多久、最多输出多少内容。', { exact: true }).count()).toBe(1) + expect(await basic.getByText('限制每条命令最多能跑多久、最多输出多少内容。', { exact: true }).count()).toBe(1) expect(await panel.getByLabel('命令超时(毫秒)').count()).toBe(0) + const extensions = panel.locator('[data-plugin-group="extensions"]') + const more = panel.getByRole('button', { name: '更多', exact: true }) + expect(await extensions.getByRole('heading', { name: '实验性插件', exact: true }).count()).toBe(1) + expect(await extensions.getByRole('button', { name: '更多', exact: true }).textContent()).toBe('更多') + expect(await extensions.getByRole('list').isVisible()).toBe(true) + expect(await panel.locator('[data-plugin-group="more"]').count()).toBe(0) + await more.focus() + await page.keyboard.press('Enter') + await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() + expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) + .toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length) + expect(await basic.count()).toBe(0) + expect(await extensions.count()).toBe(0) + expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0) + await panel.getByRole('button', { name: '查看 Git 工作树', exact: true }).click() + await panel.locator('[data-plugin-detail]').waitFor() + await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click() + await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() + await panel.getByRole('button', { name: '返回插件', exact: true }).focus() + await page.keyboard.press('Space') + expect(await extensions.getByRole('list').isVisible()).toBe(true) + expect(await panel.locator('[data-plugin-group="more"]').count()).toBe(0) + await more.click() + await page.reload({ waitUntil: 'load' }) + await openPlugins() + expect(await extensions.getByRole('list').isVisible()).toBe(true) + expect(await panel.locator('[data-plugin-group="more"]').count()).toBe(0) + const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(OFFICIAL_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) @@ -136,7 +164,7 @@ describe('web e2e: plugin configuration pages', () => { await openPage(panel, '子智能体') expect(await depth.inputValue()).toBe('1') expect(await capacity.inputValue()).toBe('8') - await panel.getByRole('button', { name: '返回插件列表', exact: true }).click() + await panel.getByRole('button', { name: '返回插件', exact: true }).click() }) it('opens field explanations with the keyboard and retains unsaved edits', async () => { @@ -160,7 +188,7 @@ describe('web e2e: plugin configuration pages', () => { await panel.getByRole('button', { name: '子智能体并行数量上限说明', exact: true }).click() const capacityRules = panel.getByRole('region', { name: '子智能体并行数量上限说明', exact: true }) expect(await capacityRules.getByText('同一主 Agent 下,所有递归层级同时存活的子智能体总数,主 Agent 不计入。达到上限时,新的启动请求会被拒绝。', { exact: true }).count()).toBe(1) - await panel.getByRole('button', { name: '返回插件列表', exact: true }).click() + await panel.getByRole('button', { name: '返回插件', exact: true }).click() await openPage(panel, '子智能体') expect(await depth.inputValue()).toBe('1') }) @@ -241,8 +269,8 @@ describe('web e2e: plugin configuration pages', () => { await timeout.waitFor({ timeout: 10_000 }) await timeout.fill('7000') - await panel.getByRole('button', { name: '返回插件列表' }).click() - await panel.getByRole('heading', { name: '官方', exact: true }).waitFor({ timeout: 10_000 }) + await panel.getByRole('button', { name: '返回插件' }).click() + await panel.getByRole('heading', { name: '内置插件配置', exact: true }).waitFor({ timeout: 10_000 }) await openPage(panel, '终端') await expect.poll(() => panel.getByLabel('命令超时(毫秒)').inputValue(), { timeout: 5_000 }).toBe('12000') @@ -342,7 +370,7 @@ describe('web e2e: plugin configuration pages', () => { await bundlePage.waitFor({ timeout: 10_000 }) // An official plugin's page is another subject; the fixture's entries render nothing for it. - await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '返回插件' }).click() await openPage(panel, '终端') const itemPage = panel.locator('[data-plugin-item-detail]') expect(await itemPage.locator('[data-live-action], [data-live-badge], [data-live-section]').count()).toBe(0) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index b0fa589d55..fa61e50def 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -148,7 +148,7 @@ describe('web e2e: plugin manager', () => { return { row: rect(row), head: rect(head), icon: rect(head?.children[0]), main: rect(main), titleRow: rect(main?.children[0]), title: rect(main?.children[0]?.firstElementChild), - description: rect(main?.children[1]), actions: rect(head?.children[2]), + description: rect(main?.children[1]), } }), } @@ -184,13 +184,11 @@ describe('web e2e: plugin manager', () => { await skeleton.waitFor({ state: 'detached' }) expect(await panel.getAttribute('aria-busy')).toBe('false') for (const action of await actions.all()) expect(await action.isDisabled()).toBe(false) - const official = panel.locator('[data-plugin-group="official"]') - await official.locator('[data-plugin-package]').first().waitFor() - // The Official group lists the shipped bundles and then the - // configuration-only items; an item card carries no action cell, so the - // placeholder rows model the first four bundle cards. - const loaded = await measure(official, ':scope > ul > li[data-plugin-package]') - expect(await official.locator(':scope > ul > li[data-plugin-package]').count()).toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length) + const basic = panel.locator('[data-plugin-group="basic"]') + await basic.locator('[data-plugin-item]').first().waitFor() + // Configuration cards lead the list; their absent switches give the text column more width. + const loaded = await measure(basic) + expect(await basic.locator(':scope > ul > li[data-plugin-item]').count()).toBe(4) expect(loaded.rows).toHaveLength(loading.rows.length) const compare = (name: string, a: typeof loading.pageHeader, b: typeof loaded.pageHeader, axes: readonly (keyof typeof a)[] = ['x', 'y', 'width', 'height']) => { for (const axis of axes) { @@ -202,11 +200,11 @@ describe('web e2e: plugin manager', () => { for (const [index, row] of loading.rows.entries()) { const real = loaded.rows[index] if (real === undefined) break - for (const part of ['row', 'head', 'icon', 'main', 'titleRow', 'actions'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part]) + for (const part of ['row', 'head', 'icon'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part]) // Painted text bars are deliberately shorter than real copy; their line origins and heights align. - for (const part of ['title', 'description'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part], ['x', 'y', 'height']) + for (const part of ['main', 'titleRow', 'title', 'description'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part], ['x', 'y', 'height']) } - facts.push(`${width}px: ${loaded.rows.length} rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px`) + facts.push(`${width}px: ${loaded.rows.length} rows; page/group headers, rows, icons and text line origins align within 0.1px`) expect(consoleWatch.pageErrors).toEqual([]) } finally { release.resolve(undefined) @@ -436,25 +434,29 @@ describe('web e2e: plugin manager', () => { expect(focusRing.radius).toBe(focusRing.cardRadius) expect(focusRing.outlineStyle).toBe('solid') expect(focusRing.outlineWidth).toBeGreaterThan(0) - // The profile's own group holds its fixture bundle and the scaffold's defaults bundle; the installation's - // optional bundles open the Official group, followed by the official plugins that registered their - // configuration, and its other bundles stay off the page. + // Profile bundles stay in Installed; configuration pages and offered bundles have their own groups. expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2) - expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length) - expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4) - expect(await panel.getByText('实验性', { exact: true }).count()) - .toBe(OPTIONAL_BUNDLES.filter(name => name.startsWith('@deepseek-ai/dsh-experimental-')).length) + expect(await panel.locator('[data-plugin-group="basic"] [data-plugin-item]').count()).toBe(4) expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-inspector"]').count()).toBe(0) expect(await panel.getByRole('switch', { name: '启用 语音输入', exact: true }).getAttribute('aria-checked')).toBe('false') expect(await panel.getByRole('switch', { name: '启用 开发者工具', exact: true }).getAttribute('aria-checked')).toBe('false') expect(await panel.getByText('查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端', { exact: true }).count()).toBe(1) expect(await panel.getByText(/Cordis|Chrome DevTools/).count()).toBe(0) + await panel.getByRole('button', { name: '更多', exact: true }).click() + await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() + expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) + .toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length) + expect(await panel.getByText('实验性', { exact: true }).count()) + .toBe(OPTIONAL_BUNDLES.filter(name => name.startsWith('@deepseek-ai/dsh-experimental-')).length) + expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0) + await panel.getByRole('button', { name: '返回插件', exact: true }).click() + expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2) // A bundle that is off still shows the rows its patch declares, without switches. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).count()).toBe(0) await panel.getByRole('button', { name: '卸载 @fixture/bundle' }).waitFor({ timeout: 5_000 }) - await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '返回插件' }).click() await expect.poll(() => panel.getByRole('button', { name: '卸载 @fixture/bundle' }).count(), { timeout: 5_000 }).toBe(0) const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) @@ -538,7 +540,7 @@ describe('web e2e: plugin manager', () => { } await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() await checkImage('[data-plugin-detail]', teamIcon, 'Agent Teams detail') - await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '返回插件' }).click() await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail') await checkImage('[data-plugin-row="fixture-search"]', fallbackIcon, 'Independent search row') @@ -546,7 +548,7 @@ describe('web e2e: plugin manager', () => { expect(await panel.locator('[data-plugin-row="fixture-review"] svg').count()).toBeGreaterThan(0) images.push('Independent review row: generic artwork') await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'icons.expected.md'), images.join('\n'), MODE) - await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '返回插件' }).click() expect(tripwire.pageErrors).toEqual([]) }) @@ -579,7 +581,7 @@ describe('web e2e: plugin manager', () => { } finally { await setLanguage('zh') } - await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '返回插件' }).click() expect(tripwire.pageErrors).toEqual([]) }) @@ -662,9 +664,9 @@ describe('web e2e: plugin manager', () => { for (const id of ['agent-team', 'tool-agent-team', 'ui-agent-team']) { await panel.locator('[data-plugin-row]', { hasText: id }).first().waitFor() } - await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '返回插件' }).click() } finally { - const back = panel.getByRole('button', { name: '返回插件列表' }) + const back = panel.getByRole('button', { name: '返回插件' }) if (await back.count() > 0) await back.click() if (await toggle.getAttribute('aria-checked') === 'true') await toggle.click() await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(0) @@ -918,7 +920,7 @@ describe('web e2e: plugin manager', () => { await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2) await rowSwitch.click() await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2) - await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '返回插件' }).click() await toggle.click() await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2) @@ -969,7 +971,7 @@ describe('web e2e: startup-applied plugin management', () => { await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).isDisabled()).toBe(true) - await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '返回插件' }).click() await toggle.click() await expect.poll(bundles, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toEqual(SCAFFOLD_BUNDLES) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index ac2f8c72f7..2b1fa6df62 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -37,6 +37,8 @@ import { } from '@deepseek-ai/dsh-experimental-webworker-runtime' import { VFS_EXAMPLE_SESSION_IDS, + VFS_EXAMPLE_REASONING, + VFS_EXAMPLE_WRAPPED_COMMAND, buildVfsExampleFiles, } from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts' import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts' @@ -455,6 +457,57 @@ async function bootPreview(origin: string, browser: Browser): Promise { await page.getByText('Update to-do list', { exact: true }).waitFor() await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor() + const reasoning = page.locator('[data-variant="think"]') + await expect.poll(() => reasoning.count()).toBe(VFS_EXAMPLE_REASONING.length) + for (const [index, text] of VFS_EXAMPLE_REASONING.entries()) { + const row = reasoning.nth(index) + await row.getByRole('button').click() + await row.getByText(text.split('\n\n').at(-1)!, { exact: true }).waitFor({ state: 'visible' }) + } + + const wrappedCall = page.locator('[data-chat-call-id="preview-wrapped-bash"]') + await wrappedCall.locator('[data-sample="bash"]').click() + const terminal = wrappedCall.locator('[data-terminal]') + const originalWidth = await terminal.evaluate(element => element.style.width) + try { + await terminal.evaluate((element) => { element.style.width = '380px' }) + const commands = terminal.locator('[data-command-text]') + expect(await commands.allTextContents()).toEqual(VFS_EXAMPLE_WRAPPED_COMMAND.slice(0, -1).split('\n')) + await commands.first().scrollIntoViewIfNeeded() + const geometry = await commands.first().evaluate((element) => { + const prompt = element.parentElement! + const header = element.closest('[class*="_header_"]')! + header.scrollTop = 0 + const dot = prompt.querySelector('[data-state="done"]') + if (dot === null) throw new Error('wrapped preview command has no successful status dot') + const range = document.createRange() + range.selectNodeContents(element) + const lines = [...range.getClientRects()].filter(rect => rect.width > 0 && rect.height > 0) + const marker = dot.getBoundingClientRect() + const viewport = header.getBoundingClientRect() + return { + wrapped: new Set(lines.map(rect => Math.round(rect.top))).size > 1, + whitespace: getComputedStyle(element).whiteSpace, + alignmentError: Math.abs(marker.top + marker.height / 2 + - (prompt.getBoundingClientRect().top + Number.parseFloat(getComputedStyle(prompt).lineHeight) / 2)), + visible: marker.top >= viewport.top && marker.bottom <= viewport.bottom, + } + }) + expect(geometry.wrapped).toBe(true) + expect(geometry.whitespace).toBe('pre-wrap') + expect(geometry.alignmentError).toBeLessThanOrEqual(1) + expect(geometry.visible).toBe(true) + expect(await terminal.locator('[data-state="done"]').count()).toBe(1) + } finally { + await terminal.evaluate((element, width) => { element.style.width = width }, originalWidth) + } + + await page.getByRole('button', { name: 'Parent workspace guide:1–8', exact: true }).click() + const preview = page.locator('[data-rightbar-col]') + await expect.poll(() => preview.locator('[data-textpreview-path]').textContent()).toBe('/dsh/workspace/PREVIEW.md') + await preview.getByRole('heading', { name: 'Preview Workspace', exact: true }).waitFor({ state: 'visible' }) + expect(await preview.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['PREVIEW.md']) + const subagents = page.getByRole('button', { name: '2 subagents', exact: true }) await subagents.waitFor({ timeout: 15_000 }) await subagents.hover() diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index ff8b5f8f4a..575b3ad645 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -18,6 +18,7 @@ const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.e const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url)) const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url)) const PREPARING_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/preparing.expected.md', import.meta.url)) +const REQUEST_LIFECYCLE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/request-lifecycle.expected.md', import.meta.url)) const MODE = webSnapshotMode() // Elicits the successful and failed sub-rows this scenario asserts. @@ -55,6 +56,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { it('drives the recorded prompt to a settled turn (all modes)', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-drive')) + const requestStates: string[] = [] if (MODE !== 'record') { expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } @@ -62,7 +64,11 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled() const gate = Promise.withResolvers() + const waiting = Promise.withResolvers() + const start = Promise.withResolvers() const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) { + waiting.resolve(undefined) + await start.promise let held = false for await (const chunk of next()) { yield chunk @@ -72,11 +78,21 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { } } }, { prepend: true }) - releasePreparation = () => { gate.resolve(undefined); dispose() } + releasePreparation = () => { start.resolve(undefined); gate.resolve(undefined); dispose() } const observePreparation = async () => { try { await input.fill(PROMPT) await input.press('Enter') + await waiting.promise + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + const request = page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true }) + await request.click() + const panel = page.getByRole('tabpanel') + await panel.locator('dl').first().getByText('Pending', { exact: true }).waitFor() + expect(await panel.getByRole('button', { name: 'Assistant Message', exact: true }).count()).toBe(0) + requestStates.push('# Waiting for output', await panel.locator('dl').first().ariaSnapshot()) + await page.getByRole('tab', { name: 'Chat', exact: true }).click() + start.resolve(undefined) const row = page.locator('[data-chat-call-id] [data-state="preparing"]').first() await row.waitFor({ state: 'attached', timeout: 30_000 }) await expandOwningTurnProcess(page, row) @@ -88,14 +104,45 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { expect(await row.locator('pre').count()).toBe(0) const group = row.locator('xpath=ancestor::*[@data-chat-group-key][1]') await compareOrRefreshGolden(PREPARING_EXPECTED, await group.ariaSnapshot(), MODE) + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + const assistant = page.locator('tr[data-kind="message"][data-running="true"]').first() + await assistant.click() + await panel.locator('dl').first().getByText('Pending', { exact: true }).waitFor() + expect(await panel.getByText('Completed', { exact: true }).count()).toBe(0) + requestStates.push('# Streaming assistant', await panel.locator('dl').first().ariaSnapshot()) + await panel.getByRole('button', { name: 'Request Timing', exact: true }).click() + expect(await panel.getByText('Not recorded', { exact: true }).count()).toBe(0) + expect(await panel.locator('dt').filter({ hasText: /^Started$/ }).locator('..').getByRole('button').count()).toBe(1) + await request.click() + await panel.locator('dl').first().getByText('Pending', { exact: true }).waitFor() + requestStates.push('# Streaming request', await panel.locator('dl').first().ariaSnapshot()) + await page.getByRole('tab', { name: 'Chat', exact: true }).click() } finally { releasePreparation?.() } } - const [sessionId] = await Promise.all([settled, observePreparation()]) - await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0) - if (MODE === 'record') { - await recordFixture(scaffold, sessionId, FIXTURE) + try { + const [sessionId] = await Promise.all([settled, observePreparation()]) + expect(sessionEvents.filter(event => event.type === 'turn/end')).toMatchObject([ + { data: { reason: { kind: 'completed' } } }, + ]) + await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0) + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + } else { + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true }).click() + const panel = page.getByRole('tabpanel') + await panel.getByText('Completed', { exact: true }).waitFor() + requestStates.push('# Settled request', await panel.locator('dl').first().ariaSnapshot()) + await panel.getByRole('button', { name: 'Assistant Message', exact: true }).click() + await panel.getByText('Completed', { exact: true }).waitFor() + requestStates.push('# Settled assistant', await panel.locator('dl').first().ariaSnapshot()) + await compareOrRefreshGolden(REQUEST_LIFECYCLE_EXPECTED, requestStates.join('\n\n'), MODE) + } + } finally { + releasePreparation?.() + await page.getByRole('tab', { name: 'Chat', exact: true }).click() } }, 200_000) diff --git a/apps/web/tests/remote-settings.e2e.ts b/apps/web/tests/remote-settings.e2e.ts new file mode 100644 index 0000000000..8f7ea86e1a --- /dev/null +++ b/apps/web/tests/remote-settings.e2e.ts @@ -0,0 +1,129 @@ +// A trusted remote browser uses the same Host settings service as loopback: +// welcome acknowledgement, model configuration and appearance persist in the +// Host profile patch across reloads and independent browser contexts. +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Browser, Locator, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + acknowledgeReloadConnectionLoss, + launchWebScaffold, watchConsole, webSnapshotMode, WELCOME_NOTICE_COPY, + type WebScaffold, +} from './scaffold.ts' +import { openSettings, ZH_BROWSER_LOCALE } from './support.ts' + +const MODE = webSnapshotMode() + +/** + * A loading welcome store hides the notice, so absence is conclusive only + * after the shared settings read has enabled the Models view. + * @param page - the loaded page. + * @returns the open settings dialog on its Models view. + */ +async function expectOnboardingSettled(page: Page): Promise { + await page.waitForSelector('#root', { timeout: 30_000 }) + await page.getByRole('textbox', { name: '选择工作区' }).waitFor({ timeout: 30_000 }) + await openSettings(page, 'zh') + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).waitFor({ timeout: 15_000 }) + expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0) + return dialog +} + +describe.skipIf(MODE === 'record')('web e2e: trusted remote host preferences', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + remoteAuthority: 'remote.localhost', + welcomeNoticePending: true, + }) + browser = await chromium.launch() + page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + locale: ZH_BROWSER_LOCALE, + }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('#root', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** Reload the page and absorb the one expected reload-time connection loss. */ + async function reloadPage(): Promise { + const warnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warnings) + } + + it('persists welcome acknowledgement, model configuration, and appearance across reloads and browser contexts', async () => { + const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) + await welcome.waitFor({ timeout: 15_000 }) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true) + + await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() + await welcome.waitFor({ state: 'detached', timeout: 15_000 }) + await expect.poll( + () => page.locator('#root').evaluate(root => (root as HTMLElement).inert), + { timeout: 15_000 }, + ).toBe(false) + const settingsPath = join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') + await expect.poll(() => readFile(settingsPath, 'utf8'), { timeout: 10_000 }) + .toContain('welcomeNoticeVersion:') + + // The acknowledged version is durable, so the notice stays away after a reload. + await reloadPage() + const dialog = await expectOnboardingSettled(page) + + await page.emulateMedia({ colorScheme: 'light' }) + await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).click() + await dialog.getByLabel('提供商', { exact: true }).selectOption('minimax-cn') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => readFile(settingsPath, 'utf8'), { timeout: 10_000 }) + .toContain('minimax-cn: {}') + + await dialog.getByRole('button', { name: '通用设置', exact: true }).click() + await dialog.getByRole('button', { name: '深色', exact: true }).click() + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe('') + await expect.poll(() => readFile(settingsPath, 'utf8'), { timeout: 10_000 }) + .toContain('preference: dark') + + await reloadPage() + await expectOnboardingSettled(page) + await dialog.getByRole('button', { name: '编辑 minimax-cn', exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme'), { timeout: 15_000 }).toBe('') + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + locale: ZH_BROWSER_LOCALE, + colorScheme: 'light', + }) + try { + const freshPage = await context.newPage() + const freshTripwire = watchConsole(freshPage) + await freshPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const freshDialog = await expectOnboardingSettled(freshPage) + await freshDialog.getByRole('button', { name: '编辑 minimax-cn', exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll( + () => freshPage.locator('body').getAttribute('data-ds-dark-theme'), + { timeout: 15_000 }, + ).toBe('') + expect(freshTripwire.warnings).toEqual([]) + expect(freshTripwire.pageErrors).toEqual([]) + } finally { + await context.close() + } + }, 180_000) +}) diff --git a/apps/web/tests/remote-welcome.e2e.ts b/apps/web/tests/remote-welcome.e2e.ts deleted file mode 100644 index b7b013dc2d..0000000000 --- a/apps/web/tests/remote-welcome.e2e.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Trusted non-loopback Web access cannot call the loopback-only settings API; -// the notice therefore advances for this browser process and returns on reload. -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { - acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode, - WELCOME_NOTICE_COPY, - type WebScaffold, -} from './scaffold.ts' -import { ZH_BROWSER_LOCALE } from './support.ts' - -const MODE = webSnapshotMode() - -describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - - beforeAll(async () => { - scaffold = await launchWebScaffold({ - remoteAuthority: 'remote.localhost', - welcomeNoticePending: true, - }) - browser = await chromium.launch() - page = await browser.newPage({ - viewport: { width: 1440, height: 960 }, - locale: ZH_BROWSER_LOCALE, - }) - tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('#root', { timeout: 30_000 }) - }, 120_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it('advances process-locally and presents the notice again after reload', async () => { - const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) - await welcome.waitFor({ timeout: 15_000 }) - expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true) - - await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() - await welcome.waitFor({ state: 'detached', timeout: 15_000 }) - await expect.poll( - () => page.locator('#root').evaluate(root => (root as HTMLElement).inert), - { timeout: 15_000 }, - ).toBe(false) - - const reloadWarnings = tripwire.warnings.length - await page.reload({ waitUntil: 'load' }) - acknowledgeReloadConnectionLoss(tripwire, reloadWarnings) - await welcome.waitFor({ timeout: 15_000 }) - expect(tripwire.warnings).toEqual([]) - expect(tripwire.pageErrors).toEqual([]) - }, 60_000) -}) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 77d9c9c54d..2b3124ad3f 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -213,7 +213,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { } }, 200_000) - it('keeps source checkout and Web context in the system prompt and cwd in user context', async () => { + it('keeps value-free Web guidance in the system prompt and process values in runtime context', async () => { if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id') const agent = scaffold.ctx.agents.get(settledSessionId) if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) @@ -230,6 +230,8 @@ describe('web e2e: fresh round trip through the real assembly', () => { const userText = agent.session.deriveMessages().filter(message => message.role === 'user') .flatMap(message => message.content.flatMap(block => block.type === 'text' ? [block.text] : [])) expect(userText.some(text => text.includes(`Current working directory: ${JSON.stringify(cwd)}.`))).toBe(true) + expect(system).not.toContain(scaffold.baseUrl) + expect(userText.some(text => text.includes(`Current DeepSeek Harness Web GUI URL: ${JSON.stringify(scaffold.baseUrl)}.`))).toBe(true) const suffix = paragraphs.slice(-2).join('\n\n') .split(REPO_ROOT).join('{{sourceRoot}}') .split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}') diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 8009cf4453..9d8d41ccf0 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -887,10 +887,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { let normalized = value .replace(/Anonymous user: [0-9a-f-]{36}(?=\.$)/gi, 'Anonymous user: {{anonymousUserId}}') + // The checkout path and listener port differ per machine and run. + .replace(/Current DeepSeek Harness implementation checkout: "(?:[^"\\]|\\.)*"\./g, 'Current DeepSeek Harness implementation checkout: "{{sourceRoot}}".') + .replace(/Current DeepSeek Harness Web GUI URL: "(?:[^"\\]|\\.)*"\./g, 'Current DeepSeek Harness Web GUI URL: "{{webUrl}}".') for (const cwd of cwdSpellings) normalized = replaceWebCwd(normalized, cwd) return normalized })) as { type?: unknown; data?: { endpoint?: unknown } } diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 085f358b85..4c6fff7f18 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -16,7 +16,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ContextFormed, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -41,6 +41,7 @@ type CheckpointSource = Extract const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url)) +const REASONING_EXPECTED_DIR = fileURLToPath(new URL('./expected/seeded-history', import.meta.url)) const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/ui.expected.md', import.meta.url)) const UI_EXPANDED_EXPECTED = fileURLToPath( @@ -856,11 +857,68 @@ describe('web e2e: seeded history renders through cold resume', () => { await terminal.getByRole('textbox', { name: 'Terminal', exact: true }).waitFor() }) + it.skipIf(MODE === 'record')('omits blank reasoning rows around recorded text in an appended fixture turn', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-blank-reasoning')) + const session = scaffold.ctx.sessions.get(SessionId(SEED_ID)) + if (session === undefined) throw new Error('seeded session is unavailable') + const events = session.snapshotEvents() + const recorded = events.flatMap(event => event.type === 'assistant/message' ? event.data.message.content : []) + .find(block => block.type === 'reasoning' && block.text.trim() !== '') + if (recorded?.type !== 'reasoning') throw new Error('seeded history requires recorded reasoning text') + const previousEnd = events.findLast(event => event.type === 'turn/end') + if (previousEnd?.type !== 'turn/end') throw new Error('seeded history requires a closed turn') + const turn = previousEnd.data.turn + 1 + const answer = 'BLANK_REASONING_FIXTURE_DONE' + // The blank blocks are a test condition around authentic recorded text, not model output. + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show recorded reasoning surrounded by blank blocks.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const message = session.append('assistant/message', { + turn, step: 1, stream: [], + message: createMessage({ + role: 'assistant', + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + content: [ + { type: 'reasoning', text: '' }, + { type: 'reasoning', text: recorded.text }, + { type: 'reasoning', text: ' \t\r\n' }, + { type: 'reasoning', text: '' }, + { type: 'text', text: answer }, + ], + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(session) + await page.getByText(answer, { exact: true }).waitFor({ timeout: 10_000 }) + const selector = `[data-chat-turn="${turn}"][data-chat-group-part="reasoning"]` + const rows = page.locator(selector).locator('[data-variant="think"]') + await expect.poll(() => rows.count(), { timeout: 10_000 }).toBe(1) + await expandOwningTurnProcess(page, rows.first()) + const toggle = rows.getByRole('button').first() + expect(await toggle.getAttribute('aria-expanded')).toBe('false') + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('true') + const body = rows.locator('[data-markdown-variant="compact"]') + await body.waitFor({ state: 'visible' }) + expect(await body.innerText()).not.toBe('') + expect(message.data.message.content[1]).toEqual({ type: 'reasoning', text: recorded.text }) + await compareOrRefreshGolden(join(REASONING_EXPECTED_DIR, 'blank-reasoning.expected.md'), [ + await captureStableAria(page, selector, scaffold.workspaceCwd), + '', + `- Reasoning rows: ${await rows.count()}`, + ].join('\n'), MODE) + }) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { // No replay fixture was installed and the llm seam is open — any stray // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(REASONING_EXPECTED_DIR, ['blank-reasoning.expected.md']) await assertFixtureInventory(SNAPSHOT_DIR, [ 'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md', 'session.v3.jsonl', 'sticky-geometry.expected.md', 'thinking-expanded.expected.md', diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index bc6e6302e2..6d6fd3e808 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -8,12 +8,12 @@ // Zero model calls: everything is pure client + persistence state on a blank // frame, so there is no fixture and a stray stream would fail loud on the // open llm seam. -import { readFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import type { Browser, Locator, Page } from 'playwright' +import type { Browser, Locator, Page, Request } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { SessionId } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -29,9 +29,17 @@ const PLUGIN_INSTANCES_EXPECTED = join(SNAPSHOT_DIR, 'plugin-instances.expected. const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md') const PLUGIN_ROW_SELECTOR = '[data-plugin-scope="preset"] [data-plugin-entry="tool-subagent"]' const MODE = webSnapshotMode() +const SCREENSHOTS = 'screenshots/collapse-timing-settings' +const SCREENSHOT_DIR = fileURLToPath(new URL(`../../../.artifacts/${SCREENSHOTS}`, import.meta.url)) const { version } = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { version: string } const versionCapture = { replacements: [[version, '{{version}}']] as const } +async function saveSettingsFailureShot(page: Page, name: string): Promise { + await mkdir(SCREENSHOT_DIR, { recursive: true }) + const directory = await mkdtemp(join(SCREENSHOT_DIR, `${name}-`)) + await saveFailureShot(page, `${SCREENSHOTS}/${basename(directory)}/failure`) +} + describe('web e2e: settings modal and General preferences', () => { let scaffold: WebScaffold let browser: Browser @@ -53,12 +61,15 @@ describe('web e2e: settings modal and General preferences', () => { }, 120_000) afterAll(async () => { - await browser?.close() - await scaffold?.close() + try { + await browser?.close() + } finally { + await scaffold?.close() + } }) it('opens the settings dialog, switches sections, and closes by every path', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-shell')) + onTestFailed(() => saveSettingsFailureShot(page, 'settings-shell')) onTestFinished(async () => { const dialog = page.getByRole('dialog', { name: '设置' }) if (await dialog.isVisible()) await page.keyboard.press('Escape') @@ -100,10 +111,13 @@ describe('web e2e: settings modal and General preferences', () => { }), }) }) - await openDocument.click() - await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) - await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) - await page.unroute('**/api/settings/openSettingsDocument') + try { + await openDocument.click() + await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) + await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) + } finally { + await page.unrouteAll({ behavior: 'wait' }) + } await dialog.getByText(`当前版本:${version}`, { exact: true }).waitFor() // Golden of the freshly opened dialog (default zh, General active). const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, versionCapture) @@ -149,8 +163,8 @@ describe('web e2e: settings modal and General preferences', () => { expect(await enabledRows.count()).toBeGreaterThan(0) expect(await enabledRows.getByText('已启用', { exact: true }).count()).toBe(0) expect(await dialog.locator('[data-plugin-scope="global"] [role="img"][aria-label="运行中"]').count()).toBe(0) - expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) - .toBe(String(expectedPluginCount)) + expect(await dialog.getByText('由 Agent 预设按会话组成', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('系统与所有会话共用', { exact: true }).count()).toBe(1) expect(await dialog.getByRole('button', { name: '内置插件', exact: true }).getAttribute('aria-current')).toBe('true') // One contribution shows as the page itself, without a tab row. expect(await dialog.getByRole('tab').count()).toBe(0) @@ -168,17 +182,11 @@ describe('web e2e: settings modal and General preferences', () => { ] as const for (const [entryId, status] of instanceRows) { const row = dialog.locator(`[data-plugin-scope="preset"] [data-plugin-entry="${entryId}"]`) - // An id that repeats the title adds nothing, so its card shows no chip and its name omits it. - const repeatsTitle = entryId === 'tool-subagent' - const name = repeatsTitle ? `tool-subagent, ${status}` : `tool-subagent, ${entryId}, ${status}` + const name = `子智能体委派工具, ${entryId}, ${status}` const trigger = row.getByRole('button', { name, exact: true }) await trigger.waitFor({ timeout: 10_000 }) expect(await trigger.getAttribute('aria-expanded')).toBe('false') const identity = row.locator('code') - if (repeatsTitle) { - expect(await identity.count()).toBe(0) - continue - } expect(await identity.textContent()).toBe(entryId) expect(await identity.getAttribute('title')).toBe(entryId) } @@ -190,7 +198,7 @@ describe('web e2e: settings modal and General preferences', () => { ) await compareOrRefreshGolden(PLUGIN_INSTANCES_EXPECTED, instancesSnapshot, MODE) await dialog.getByRole('button', { - name: 'tool-subagent, tool-subagent-fork, 已启用', + name: '子智能体委派工具, tool-subagent-fork, 已启用', exact: true, }).click() expect(await dialog.locator('[data-plugin-entry="tool-subagent-fork"] button') @@ -864,7 +872,7 @@ describe('web e2e: settings modal and General preferences', () => { const fresh = await launchWebScaffold({ developerTools: false }) const frPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'fr-FR' }) const frTripwire = watchConsole(frPage) - onTestFailed(() => saveFailureShot(frPage, 'web-e2e-settings-unshipped-language')) + onTestFailed(() => saveSettingsFailureShot(frPage, 'settings-unshipped-language')) try { await frPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' }) await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -901,6 +909,7 @@ describe('web e2e: settings modal and General preferences', () => { onTestFinished(() => fresh.close()) const withoutBrowser = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) onTestFinished(() => withoutBrowser.close()) + onTestFailed(() => saveSettingsFailureShot(withoutBrowser, 'settings-no-browser')) const browserConsole = watchConsole(withoutBrowser) await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' }) await openSettings(withoutBrowser, 'en') @@ -920,8 +929,218 @@ describe('web e2e: settings modal and General preferences', () => { 'dialog-en.expected.md', 'dialog-no-browser.expected.md', 'dialog.expected.md', + 'collapse-timing-en.expected.md', + 'collapse-timing-zh.expected.md', 'plugin-instances.expected.md', 'plugins.expected.md', ]) }) }) + +describe('web e2e: Host-backed collapse timing preference', () => { + let scaffold: WebScaffold + let browser: Browser + const uiWait = { timeout: 10_000 } + + beforeAll(async () => { + scaffold = await launchWebScaffold({ developerTools: false }) + browser = await chromium.launch({ headless: true }) + }) + + afterAll(async () => { + try { + await browser?.close() + } finally { + await scaffold?.close() + } + }) + + it.each([ + { + locale: 'zh', browserLocale: ZH_BROWSER_LOCALE, theme: 'dark', + settings: '设置', close: '关闭', details: '工作步骤展示', compact: '简洁', detailed: '详细', + title: '工作步骤收起时机', completion: '回答结束后', nextInput: '下次有新消息时', + description: '选择何时自动收起工作步骤', + }, + { + locale: 'en', browserLocale: 'en-US', theme: 'light', + settings: 'Settings', close: 'Close', details: 'Work details', compact: 'Compact', detailed: 'Detailed', + title: 'When to Collapse Work Details', completion: 'On completion', nextInput: 'On next message', + description: 'Choose when to automatically collapse work details', + }, + ] as const)('persists $locale collapse timing on $theme across Clients', async (copy) => { + const contextOptions = { + viewport: { width: 1680, height: 1000 }, locale: copy.browserLocale, + colorScheme: copy.theme, timezoneId: 'Asia/Shanghai', + } + await scaffold.ctx.settings.update('ui-chat', { collapseTiming: 'completion' }) + const context = await browser.newContext(contextOptions) + onTestFinished(() => context.close()) + const page = await context.newPage() + onTestFailed(() => saveSettingsFailureShot(page, `settings-${copy.locale}`)) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(page, copy.locale) + const dialog = page.getByRole('dialog', { name: copy.settings, exact: true }) + const details = dialog.getByText(copy.details, { exact: true }).locator('../..') + await details.getByRole('button').click() + expect(await page.getByRole('menuitem').count()).toBe(4) + // The persistence checks require an explicit saved mode, not the client's default. + await page.getByRole('menuitem', { name: copy.compact, exact: true }).click() + await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, uiWait) + .toMatchObject({ transcriptView: 'compact' }) + await details.getByRole('button', { name: copy.compact, exact: true }).click() + await page.getByRole('menuitem', { name: copy.detailed, exact: true }).click() + await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, uiWait) + .toMatchObject({ transcriptView: 'detailed' }) + await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), uiWait) + .toBe(copy.theme === 'dark') + const timing = dialog.getByText(copy.title, { exact: true }).locator('../..') + expect(await timing.count()).toBe(1) + expect(await timing.evaluate(element => element.previousElementSibling?.textContent)).toContain(copy.details) + await timing.getByText(copy.description, { exact: true }).waitFor() + await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor() + let screenshotDir: string | undefined + if (MODE !== 'record') { + await mkdir(SCREENSHOT_DIR, { recursive: true }) + screenshotDir = await mkdtemp(join(SCREENSHOT_DIR, `settings-${copy.locale}-${copy.theme}-`)) + await page.screenshot({ path: join(screenshotDir, 'panel.png'), fullPage: true }) + } + + await page.evaluate(() => { + localStorage.setItem('collapse-timing-e2e-unrelated', 'retained') + sessionStorage.setItem('collapse-timing-e2e-unrelated', 'retained') + }) + const readPersistence = async (target: Page = page) => ({ + host: await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), + settings: structuredClone(scaffold.ctx.settings.describe({ redactSecrets: true }).map(({ ns, value }) => ({ ns, value }))), + storage: await target.evaluate(() => ({ + local: Object.fromEntries(Object.entries(localStorage)), + session: Object.fromEntries(Object.entries(sessionStorage)), + })), + }) + const before = await readPersistence() + const settingsWrites: string[] = [] + const recordSettingsWrite = (request: Request): void => { + const path = new URL(request.url()).pathname + if (request.method() === 'POST' && path === '/api/settings/mutate') settingsWrites.push(path) + } + context.on('request', recordSettingsWrite) + await timing.getByRole('button', { name: copy.completion, exact: true }).click() + await page.getByRole('menuitem', { name: copy.nextInput, exact: true }).waitFor() + expect(await page.getByRole('menuitem').allTextContents()).toEqual([copy.completion, copy.nextInput]) + await compareOrRefreshGolden( + join(SNAPSHOT_DIR, `collapse-timing-${copy.locale}.expected.md`), + await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), + MODE, + ) + if (screenshotDir !== undefined) { + await page.screenshot({ path: join(screenshotDir, 'menu.png'), fullPage: true }) + } + const menu = page.getByRole('menu') + await page.keyboard.press('Escape') + await menu.waitFor({ state: 'hidden' }) + expect(await dialog.isVisible()).toBe(true) + await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor() + await timing.getByRole('button').click() + await menu.waitFor() + await timing.getByText(copy.title, { exact: true }).click() + await menu.waitFor({ state: 'hidden' }) + expect(await dialog.isVisible()).toBe(true) + await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor() + + await page.setViewportSize({ width: 640, height: 480 }) + await timing.getByRole('button').click() + await menu.waitFor() + await expect.poll(() => menu.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { + fits: rect.width > 0 && rect.height > 0 + && rect.left > 0 && rect.top > 0 + && rect.right < window.innerWidth && rect.bottom < window.innerHeight, + uncovered: [...element.querySelectorAll('[role="menuitem"]')].every((item) => { + const bounds = item.getBoundingClientRect() + return item.contains(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)) + }), + } + }), uiWait).toEqual({ fits: true, uncovered: true }) + if (screenshotDir !== undefined) { + await page.screenshot({ path: join(screenshotDir, 'menu-compact.png'), fullPage: true }) + } + await page.getByRole('menuitem', { name: copy.nextInput, exact: true }).click() + await menu.waitFor({ state: 'hidden' }) + await page.setViewportSize({ width: 1680, height: 1000 }) + await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor() + await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, uiWait) + .toMatchObject({ transcriptView: 'detailed', collapseTiming: 'next-input' }) + const saved = await readPersistence() + expect(saved.host).not.toBe(before.host) + expect(saved.storage).toEqual(before.storage) + await dialog.getByRole('button', { name: copy.close, exact: true }).click() + await openSettings(page, copy.locale) + await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor() + expect(await readPersistence()).toEqual(saved) + await dialog.getByRole('button', { name: copy.close, exact: true }).click() + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await openSettings(page, copy.locale) + await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor() + acknowledgeReloadConnectionLoss(tripwire, warningStart) + expect(await readPersistence()).toEqual(saved) + if (screenshotDir !== undefined) { + await page.screenshot({ path: join(screenshotDir, 'reloaded.png'), fullPage: true }) + } + + const sharedPage = await context.newPage() + onTestFailed(() => saveSettingsFailureShot(sharedPage, `settings-${copy.locale}-shared-tab`)) + const sharedTripwire = watchConsole(sharedPage) + await sharedPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(sharedPage, copy.locale) + const sharedTiming = sharedPage.getByRole('dialog', { name: copy.settings, exact: true }) + .getByText(copy.title, { exact: true }).locator('../..') + await sharedTiming.getByRole('button', { name: copy.nextInput, exact: true }).waitFor() + const sharedPersistence = await readPersistence(sharedPage) + expect(sharedPersistence.host).toBe(saved.host) + expect(sharedPersistence.settings).toEqual(saved.settings) + expect(sharedPersistence.storage.local).toEqual(before.storage.local) + if (screenshotDir !== undefined) { + await sharedPage.screenshot({ path: join(screenshotDir, 'shared-tab.png'), fullPage: true }) + } + + const isolatedContext = await browser.newContext(contextOptions) + onTestFinished(() => isolatedContext.close()) + isolatedContext.on('request', recordSettingsWrite) + const isolatedPage = await isolatedContext.newPage() + onTestFailed(() => saveSettingsFailureShot(isolatedPage, `settings-${copy.locale}-isolated-context`)) + const isolatedTripwire = watchConsole(isolatedPage) + await isolatedPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(isolatedPage, copy.locale) + const isolatedTiming = isolatedPage.getByRole('dialog', { name: copy.settings, exact: true }) + .getByText(copy.title, { exact: true }).locator('../..') + await isolatedTiming.getByRole('button', { name: copy.nextInput, exact: true }).waitFor() + const isolatedPersistence = await readPersistence(isolatedPage) + expect(isolatedPersistence.host).toBe(saved.host) + expect(isolatedPersistence.settings).toEqual(saved.settings) + if (screenshotDir !== undefined) { + await isolatedPage.screenshot({ path: join(screenshotDir, 'isolated-context.png'), fullPage: true }) + } + + expect(await readPersistence()).toEqual(saved) + await timing.getByRole('button', { name: copy.nextInput, exact: true }).click() + await page.getByRole('menuitem', { name: copy.completion, exact: true }).click() + await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor() + await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, uiWait) + .toMatchObject({ transcriptView: 'detailed', collapseTiming: 'completion' }) + expect((await readPersistence()).storage).toEqual(before.storage) + await sharedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor() + await isolatedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor() + expect(settingsWrites.length).toBeGreaterThan(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + expect(sharedTripwire.pageErrors).toEqual([]) + expect(sharedTripwire.warnings).toEqual([]) + expect(isolatedTripwire.pageErrors).toEqual([]) + expect(isolatedTripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/shell-command-details.e2e.ts b/apps/web/tests/shell-command-details.e2e.ts index a219fb6941..f9c50ad056 100644 --- a/apps/web/tests/shell-command-details.e2e.ts +++ b/apps/web/tests/shell-command-details.e2e.ts @@ -8,21 +8,22 @@ import { seedSession, watchConsole, webSnapshotMode, } from './scaffold.ts' import { expandOwningTurnProcess, newEnglishPage } from './support.ts' -import { expectCommandFocusAfterResize, expectSelectableCommandText } from './terminal-command-browser.ts' +import { expectFirstLineTerminalState, expectWrappedCommandText } from './terminal-command-browser.ts' const COMMAND = 'node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log' const ARGS = { command: COMMAND, description: 'Start the diagnostic worker', run_in_background: true } const OUTPUT = 'started background job bash-1' -const EXPECTED = fileURLToPath(new URL('./expected/shell-command-details/background.expected.md', import.meta.url)) +const LONG_ARGS = { ...ARGS, command: `${COMMAND} --token=${'x'.repeat(1024)}` } -/** A settled background launch acknowledgement rendered from durable call arguments. */ -function backgroundFixture(): string { +/** A settled shell call rendered from durable arguments and its original result. */ +function shellFixture(arguments_: typeof ARGS, output: string): string { const session = Session.create(SessionId('shell-command-details-source')) const callId = ToolCallId('shell-command-details-background') - const args = JSON.stringify(ARGS) + const args = JSON.stringify(arguments_) session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Start the diagnostic worker in the background.' }], + content: [{ type: 'text', text: arguments_.run_in_background + ? 'Start the diagnostic worker in the background.' : 'Run the diagnostic worker.' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('step/start', { turn: 1, step: 1 }) @@ -36,7 +37,7 @@ function backgroundFixture(): string { const call = session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: args }) session.append('tool/result', { turn: 1, step: 1, - message: createToolResultMessage({ callId, content: [{ type: 'text', text: OUTPUT }], isError: false }), + message: createToolResultMessage({ callId, content: [{ type: 'text', text: output }], isError: false }), }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) session.append('step/end', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 2 }) @@ -56,10 +57,18 @@ function backgroundFixture(): string { ].join('\n') } -it('expands a background Bash launch to selectable arguments and its original acknowledgement', async () => { +it.each([ + { name: 'background', args: ARGS, output: OUTPUT }, + { name: 'background-long-token', args: LONG_ARGS, output: OUTPUT }, + { + name: 'foreground-multiline', + args: { ...LONG_ARGS, command: `${LONG_ARGS.command}\necho done\n`, run_in_background: false }, + output: 'done\n[exit code: 0]', + }, +])('expands $name to wrapped commands with only foreground output', async ({ name, args, output }) => { const scaffold = await launchWebScaffold({}) try { - await seedSession(scaffold, backgroundFixture(), 'shell-command-details-browser') + await seedSession(scaffold, shellFixture(args, output), 'shell-command-details-browser') const browser = await chromium.launch() try { const page = await newEnglishPage(browser) @@ -76,25 +85,52 @@ it('expands a background Bash launch to selectable arguments and its original ac expect(await row.getAttribute('aria-expanded')).toBe('false') await row.click() const card = row.locator('..') - const input = card.locator('[data-command-text]') + const commands = card.locator('[data-command-text]') + const input = commands.first() await input.waitFor() - expect(await input.textContent()).toBe(JSON.stringify(ARGS, null, 2)) - expect(await card.getByText(OUTPUT, { exact: true }).count()).toBe(1) - expect(await card.locator('[data-terminal]').count()).toBe(0) + const background = args.run_in_background + const selectableText = args.command.split('\n')[0]! + const inputSection = card.locator('[data-terminal] [class*="_header_"]') + expect(await input.textContent()).toBe(selectableText) + if (background) { + expect(await commands.allTextContents()).toEqual([args.command]) + expect(await card.locator('[data-terminal]').count()).toBe(1) + expect(await card.getByText(OUTPUT, { exact: true }).count()).toBe(0) + expect(await card.locator('[data-terminal][data-body], [data-terminal][data-running], [data-terminal] [data-state]').count()).toBe(0) + expect(await card.getByRole('button', { name: 'Copy', exact: true }).count()).toBe(0) + } else { + expect(await commands.allTextContents()).toEqual([selectableText, 'echo done']) + expect(await card.locator('[data-terminal]').count()).toBe(1) + expect(await card.getByText('done', { exact: true }).count()).toBe(1) + } expect(await card.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1) for (const colorScheme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme }) await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark') - for (const width of ['', '380px']) { + for (const width of ['', '380px', '']) { await card.evaluate((element, value) => { element.style.width = value }, width) - await input.scrollIntoViewIfNeeded() - await expectSelectableCommandText(page, input, COMMAND, 'Command arguments') - await expectCommandFocusAfterResize(card, input) + if (!background && width === '380px') await expectFirstLineTerminalState(card.locator('[data-terminal]'), 'done') + await expectWrappedCommandText(page, input, selectableText, inputSection) + expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + if (name !== 'background') { + expect(await inputSection.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true) + await inputSection.evaluate((element) => { element.scrollTop = 0 }) + await row.focus() + await page.keyboard.press('Tab') + const focusTarget = background ? inputSection : card.getByRole('button', { name: 'Copy', exact: true }) + expect(await focusTarget.evaluate(element => document.activeElement === element)).toBe(true) + await page.keyboard.press('End') + await expect.poll(() => inputSection.evaluate(element => + element.scrollTop + element.clientHeight >= element.scrollHeight - 1)).toBe(true) + await page.keyboard.press('Home') + await expect.poll(() => inputSection.evaluate(element => element.scrollTop)).toBe(0) + } } await card.evaluate((element) => { element.style.width = '' }) } await page.emulateMedia({ colorScheme: null }) - await compareOrRefreshGolden(EXPECTED, + const expected = fileURLToPath(new URL(`./expected/shell-command-details/${name}.expected.md`, import.meta.url)) + await compareOrRefreshGolden(expected, await captureStableAria(page, '[data-chat-call-id="shell-command-details-background"]', scaffold.workspaceCwd), webSnapshotMode()) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -105,3 +141,94 @@ it('expands a background Bash launch to selectable arguments and its original ac await scaffold.close() } }) + +it.each([ + { name: 'bash', background: false }, + { name: 'pwsh', background: false }, + { name: 'bash', background: true }, + { name: 'pwsh', background: true }, +] as const)('keeps the $name command mounted through settlement (background=$background)', async ({ name, background }) => { + const scaffold = await launchWebScaffold({}) + try { + const id = await seedSession(scaffold, shellFixture(ARGS, OUTPUT), 'shell-status-dot-browser') + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('treeitem').first().click() + await page.getByRole('treeitem').nth(1).click() + await page.locator('[data-sample="bash"]').waitFor({ state: 'attached' }) + const resolved = await scaffold.ctx.sessionController.resolveAgent(id) + if ('error' in resolved) throw resolved.error + const session = resolved.agent.session + const callId = ToolCallId(`status-dot-${name}-${background ? 'background' : 'foreground'}`) + const args = JSON.stringify({ command: LONG_ARGS.command, description: `Long ${name} command`, run_in_background: background }) + session.append('turn/start', { turn: 2 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show a long running command.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('step/start', { turn: 2, step: 1 }) + session.append('assistant/message', { + turn: 2, step: 1, stream: [], + message: createAssistantMessage({ + content: [{ type: 'tool-call', id: callId, name, arguments: args }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + const call = session.append('tool/call', { turn: 2, step: 1, callId, name, arguments: args }) + await scaffold.ctx.sessions.flush(session) + const callRow = page.locator(`[data-chat-call-id="${callId}"]`) + const toggle = callRow.locator('[data-expandable]').first() + await toggle.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, toggle) + await toggle.click() + const card = callRow.locator('[data-terminal]') + await card.waitFor() + await card.evaluate((element) => { element.style.width = '380px' }) + await card.scrollIntoViewIfNeeded() + const header = card.locator('[class*="_header_"]') + const mountedCard = await card.elementHandle() + if (mountedCard === null) throw new Error('Running command card is not mounted') + expect(await header.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true) + expect(await card.getByRole('button', { name: 'Copy', exact: true }).count()).toBe(0) + for (const state of ['ongoing', 'done'] as const) { + if (state === 'done') { + session.append('tool/result', { + turn: 2, step: 1, + message: createToolResultMessage({ + callId, content: [{ type: 'text', text: background ? OUTPUT : 'status dot output\n[exit code: 0]' }], isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + session.append('step/end', { turn: 2, step: 1 }) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(session) + } + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').evaluate(element => + element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark') + if (background && state === 'done') { + await expect.poll(() => card.getAttribute('data-running')).toBeNull() + expect(await card.evaluate((element, previous) => element === previous, mountedCard)).toBe(true) + expect(await toggle.getAttribute('aria-expanded')).toBe('true') + expect(await card.getAttribute('data-body')).toBeNull() + expect(await card.getAttribute('data-running')).toBeNull() + expect(await card.locator('[data-state]').count()).toBe(0) + } else await expectFirstLineTerminalState(card, state) + expect(await card.locator('[data-command-text]').textContent()).toBe(LONG_ARGS.command) + } + } + expect(await card.getByText('status dot output', { exact: true }).count()).toBe(background ? 0 : 1) + expect(await card.getByText(OUTPUT, { exact: true }).count()).toBe(0) + expect(await card.getByRole('button', { name: 'Copy', exact: true }).count()).toBe(background ? 0 : 1) + expect(await callRow.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 9723ae3494..bc77481b99 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -140,6 +140,7 @@ describe('web e2e: skill invocation policy through the real host', () => { await option.click() const skill = input.locator('[data-composer-text-ref]').filter({ hasText: '/policy-shared' }) const file = input.locator('[data-composer-chip]') + await file.locator('span').first().waitFor({ state: 'visible' }) const draft = await input.textContent() const alignment = await input.evaluate((el) => { const skill = el.querySelector('[data-composer-text-ref]')! diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 31f102bff7..cf1546ccf2 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -476,8 +476,13 @@ describe('dsh web keyless CLI smoke', () => { .flatMap(message => message.content ?? []) .find(block => block.type === 'text' && block.text?.includes('web-workspace-context-probe')) const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd() - .replace('{{webUrl}}', new URL(baseUrl).origin) expect(captured.system).toContain(expectedWebSection) + expect(captured.system).not.toContain(new URL(baseUrl).origin) + expect(captured.messages?.filter(message => message.role === 'user') + .flatMap(message => message.content ?? []) + .some(block => block.type === 'text' + && block.text?.includes(`Current DeepSeek Harness Web GUI URL: ${JSON.stringify(new URL(baseUrl).origin)}.`))) + .toBe(true) expect(workspaceMessage).toMatchInlineSnapshot(` { "text": " diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 6b6cd918b7..2324f0ae2b 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -1,10 +1,11 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { zstdCompressSync } from 'node:zlib' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay' import { SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, SessionSeq, @@ -12,10 +13,11 @@ import { } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-agent' import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' +import { checkpointRecord, projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' import { acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, - launchWebScaffold, readPersistedEvents, selectedSessionFixture, watchConsole, + launchWebScaffold, readPersistedEvents, realizeSeedFixture, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' @@ -32,6 +34,9 @@ const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent- const SIDEBAR_CHAT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar-chat.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url)) +const MIGRATING_CHILD_EXPECTED = fileURLToPath(new URL( + './expected/subagent-conversation/migration-required-child.expected.md', import.meta.url, +)) const MODE = webSnapshotMode() const LABEL = 'event-sourcing researcher' const ONE_SHOT_LABEL = 'event-sourcing reviewer' @@ -120,6 +125,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = paceMs: 25, }) browser = await chromium.launch() + await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/non-migrating-session-reads', import.meta.url)), { recursive: true }) page = await newEnglishPage(browser) page.on('request', (request) => { const path = new URL(request.url()).pathname @@ -320,6 +326,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, ]) } + await waitForCacheRow(scaffold, childHeader) + const cache = scaffold.ctx.storageDomain.get(projectionCacheDomainSpec.name) + if (cache === undefined) throw new Error('projection cache is not open') + const table = cache.table('sessions') + const record = checkpointRecord.parse(table.get(childId)) + const { subagentCatalog: _catalog, ...rows } = record.rows + await table.put(childId, { ...record, rows }) // These two cold fixtures were authored after the page's initial // session.list and intentionally emitted no api-session/added event. Reload // to exercise the restart baseline that discovers their full lineage. @@ -362,8 +375,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await writeComposerDraft(page, input, '') }) - it('expands a persisted grandchild progressively without activating either level', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree')) + it('recovers a current-format cold child catalog cache miss without activating either level', async () => { + onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-subagent-tree')) await page.getByRole('button', { name: '2 subagents' }).hover() const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' }) expect(await catalogTree.evaluate((element) => { @@ -379,12 +392,22 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await oneShotRow.locator('[data-state="done"]').count()).toBe(1) expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1) expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s') - await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() + const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession') + try { + const projections = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/projections') + await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() + await projections + await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) + expect(observe.mock.calls.some(([id]) => id === childId)).toBe(true) + expect(observe.mock.calls.some(([id]) => id === grandchildId)).toBe(false) + } finally { + observe.mockRestore() + } const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) const childLabel = await childRow.getAttribute('aria-label') await page.waitForTimeout(1_100) expect(await childRow.getAttribute('aria-label')).toBe(childLabel) - await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) + expect(await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).textContent()).toContain('continuable') expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() const snapshot = await captureStableAria( @@ -544,7 +567,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = Math.round(clickAreaBox!.x - menuBox!.x), Math.round(menuBox!.x + menuBox!.width - clickAreaBox!.x - clickAreaBox!.width), // The outer menu padding insets the scrollable tree rows. - ]).toEqual([3, 3]) + ]).toEqual([4, 4]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), @@ -657,3 +680,143 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined() }) }) + +describe('web e2e: migration-required subagent catalog', () => { + const parentId = sessionId('migration-catalog-parent') + const childId = sessionId('migration-catalog-child') + const grandchildId = sessionId('migration-catalog-grandchild') + const childLabel = 'historical researcher' + const grandchildLabel = 'historical editor' + const predecessors: { id: SessionId; directory: string; path: string; source: Buffer }[] = [] + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + if (MODE === 'record') throw new Error('migration-required subagent catalog is a keyless assembled snapshot') + scaffold = await launchWebScaffold() + const fixture = await readFile(BASE_FIXTURE, 'utf8') + const createdAt = 1784998084442 + await seedSession(scaffold, fixture, parentId, undefined, { createdAt }) + const parentHandle = await scaffold.ctx.sessionPersistence.open(parentId, 'write') + try { + const { events } = await parentHandle.read() + const last = events.at(-1) + if (last === undefined) throw new Error('parent seed has no events') + await parentHandle.append([{ + type: 'subagent/catalog', seq: SessionSeq(last.seq + 1), time: last.time + 1, + data: { version: 1, childId, childCreatedAt: createdAt + 1, mode: 'continuable', label: childLabel }, + }]) + } finally { + await parentHandle.close() + } + const projects = (await readdir(scaffold.persistenceRoot, { withFileTypes: true })).filter(entry => entry.isDirectory()) + expect(projects).toHaveLength(1) + const project = projects[0] + if (project === undefined) throw new Error('parent seed has no project directory') + // Keep historical bytes on disk; seeding through the writer would migrate them before the browser opens. + for (const [id, parentSession, label, depth] of [ + [childId, parentId, childLabel, 1], + [grandchildId, childId, grandchildLabel, 2], + ] as const) { + const [header, ...events] = realizeSeedFixture(scaffold, fixture, id).trimEnd().split('\n') + .map((line): Record => JSON.parse(line) as Record) + expect(header?.version).toBe(3) + const body = [ + ...events, + { type: 'subagent/descriptor', data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'spawn', label }) }, + ...id === childId ? [{ + type: 'subagent/catalog', + data: { version: 0, childId: grandchildId, childCreatedAt: createdAt + 2, mode: 'continuable', label: grandchildLabel }, + }] : [], + ] + const rows = [ + { ...header, id, createdAt: createdAt + depth, cwd: scaffold.workspaceCwd, parentSession, origin: 'subagent', delegationDepth: depth }, + ...body.map((event, seq) => ({ ...event, seq, time: createdAt + depth + seq + 1 })), + ] + const directory = join(scaffold.persistenceRoot, project.name, id) + const path = join(directory, 'session.v3.jsonl.zstd') + const source = Buffer.concat(rows.map(row => zstdCompressSync(Buffer.from(`${JSON.stringify(row)}\n`)))) + await mkdir(directory, { recursive: true }) + await writeFile(path, source) + predecessors.push({ id, directory, path, source }) + } + for (const { id } of predecessors) { + const stored = await scaffold.ctx.sessionPersistence.stat(id) + expect(stored?.formatStatus).toBe('migration-required') + if (stored === undefined) throw new Error(`historical fixture ${id} is missing`) + expect(scaffold.ctx.sessionProjectionCache.cachedSnapshot(stored.header)).toBeUndefined() + } + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'migration-required subagent Web teardown failed') + }) + + it('stops at a migration-required child until its history opens', async () => { + onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/migration-required-subagent')) + const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession') + const projections = vi.spyOn(scaffold.ctx.sessionController, 'projections') + const model = vi.spyOn(scaffold.ctx.llm, 'stream') + try { + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const workspaceRow = page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').first() + await workspaceRow.click() + await page.locator(`[data-row-key="session:${parentId}"]`).click() + await page.getByRole('button', { name: '1 subagent', exact: true }).hover() + const tree = page.getByRole('tree', { name: 'Subagent sessions' }) + const childRow = tree.getByRole('treeitem', { name: new RegExp(childLabel) }) + await childRow.waitFor() + await expect.poll(() => childRow.textContent()).toContain('Migration required') + await expect.poll(() => childRow.getByRole('button', { name: `Expand ${childLabel} descendants`, exact: true }).count()).toBe(0) + expect(await childRow.getAttribute('aria-expanded')).toBeNull() + expect(await childRow.textContent()).toContain('Migration required') + expect(await childRow.textContent()).not.toContain('unknown mode') + expect(await childRow.getByRole('button', { name: `Open ${childLabel} in sidebar`, exact: true }).isEnabled()).toBe(true) + expect(await tree.getByRole('treeitem').count()).toBe(1) + for (const { id, directory, path, source } of predecessors) { + expect(scaffold.ctx.agents.get(id)).toBeUndefined() + expect(await readdir(directory)).toEqual(['session.v3.jsonl.zstd']) + expect(await readFile(path)).toEqual(source) + } + const before = await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd) + expect(observe.mock.calls.some(([id]) => id === childId || id === grandchildId)).toBe(false) + expect(projections).not.toHaveBeenCalled() + + await childRow.click() + await page.getByRole('button', { name: `Switch subagent: ${childLabel}`, exact: true }).waitFor() + await page.getByRole('button', { name: '1 subagent', exact: true }).hover() + const grandchildRow = tree.getByRole('treeitem', { name: new RegExp(grandchildLabel) }) + await grandchildRow.waitFor() + expect(await grandchildRow.textContent()).toContain('Migration required') + expect(await grandchildRow.getAttribute('aria-expanded')).toBeNull() + expect(await grandchildRow.getByRole('button', { name: `Expand ${grandchildLabel} descendants`, exact: true }).count()).toBe(0) + expect(projections).not.toHaveBeenCalled() + expect(observe.mock.calls.some(([id]) => id === childId)).toBe(true) + const after = await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd) + if (MODE === 'refresh') await mkdir(dirname(MIGRATING_CHILD_EXPECTED), { recursive: true }) + await compareOrRefreshGolden(MIGRATING_CHILD_EXPECTED, + `# Before opening the child\n\n${before}\n\n# After opening the child\n\n${after}`, MODE) + for (const { id, path, source } of predecessors) { + expect(scaffold.ctx.agents.get(id)).toBeUndefined() + expect(await readFile(path)).toEqual(source) + } + expect(observe.mock.calls.some(([id]) => id === grandchildId)).toBe(false) + expect(model).not.toHaveBeenCalled() + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + projections.mockRestore() + model.mockRestore() + observe.mockRestore() + } + }) +}) diff --git a/apps/web/tests/terminal-command-browser.ts b/apps/web/tests/terminal-command-browser.ts index a1ebb77850..fb715cecea 100644 --- a/apps/web/tests/terminal-command-browser.ts +++ b/apps/web/tests/terminal-command-browser.ts @@ -64,6 +64,138 @@ export async function expectSelectableTerminalCommand(page: Page, card: Locator, } } +/** + * Select and copy text that wraps through the consumer's own stylesheet. + * @param page - Browser page that owns the text. + * @param command - One authored command line or the complete formatted arguments. + * @param expected - Exact selectable text, including authored whitespace. + * @param scrollport - Existing vertical scroll container around the text. + * @returns Observed wrapping and native clipboard text for a semantic snapshot. + */ +export async function expectWrappedCommandText( + page: Page, command: Locator, expected: string, scrollport: Locator, +): Promise<{ wrapped: boolean; text: string | null; copiedText: string }> { + expect(await command.textContent()).toBe(expected) + expect(await command.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap') + await expectNoCommandTabStop(command) + const wrapped = await command.evaluate((element, value) => { + const text = element.firstChild + if (!(text instanceof Text)) throw new Error('Command has no text node') + const longest = value.split('\n').reduce((a, b) => a.length >= b.length ? a : b) + const offset = value.indexOf(longest) + const range = document.createRange() + range.setStart(text, offset) + range.setEnd(text, offset + longest.length) + const rows = [...range.getClientRects()].filter(rect => rect.width > 0 && rect.height > 0) + .map(rect => Math.round(rect.top)) + return new Set(rows).size > 1 + }, expected) + expect(wrapped).toBe(true) + expect(await scrollport.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + const originalScrollTop = await scrollport.evaluate(element => element.scrollTop) + const geometry = () => command.evaluate((element) => { + const text = element.firstChild + if (!(text instanceof Text) || text.length === 0) throw new Error('Command has no text node') + const first = document.createRange() + first.setStart(text, 0) + first.setEnd(text, 1) + const last = document.createRange() + last.setStart(text, text.length - 1) + last.setEnd(text, text.length) + const head = first.getBoundingClientRect() + const tail = last.getBoundingClientRect() + return { head: { x: head.left, y: head.top + head.height / 2 }, tail: { x: tail.right, y: tail.top + tail.height / 2 } } + }) + try { + await scrollport.scrollIntoViewIfNeeded() + await scrollport.evaluate((element) => { element.scrollTop = 0 }) + const start = await geometry() + const viewport = await scrollport.boundingBox() + if (viewport === null) throw new Error('Command scrollport is not visible') + await page.mouse.move(start.head.x + 0.1, start.head.y) + await page.mouse.down() + try { + if (start.tail.y > viewport.y + viewport.height) { + await page.mouse.move(start.head.x + 0.1, viewport.y + viewport.height - 2) + await page.mouse.wheel(0, await scrollport.evaluate(element => element.scrollHeight)) + await expect.poll(async () => (await geometry()).tail.y < viewport.y + viewport.height).toBe(true) + } + const end = await geometry() + await page.mouse.move(end.tail.x - 0.1, end.tail.y, { steps: 5 }) + } finally { + await page.mouse.up() + } + await expect.poll(() => page.evaluate(() => document.getSelection()?.toString())).toBe(expected) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + const sentinel = `${expected}\nclipboard-before-copy` + await page.evaluate(value => navigator.clipboard.writeText(value), sentinel) + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(sentinel) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(expected) + return { wrapped, text: await command.textContent(), copiedText: await page.evaluate(() => navigator.clipboard.readText()) } + } finally { + await scrollport.evaluate((element, top) => { element.scrollTop = top }, originalScrollTop) + await command.evaluate(() => { document.getSelection()?.removeAllRanges() }) + } +} + +/** + * Verify a chat terminal wraps at a narrow width without changing its text. + * @param page - Browser page that owns the card. + * @param card - Chat terminal card with consumer-owned wrapping styles. + * @param expected - Complete single authored command line. + * @param width - Narrow card width that forces soft wrapping. + * @returns Observed command wrapping and copied text in both themes. + */ +export async function expectWrappedTerminalCommand(page: Page, card: Locator, expected: string, width: number) { + const originalWidth = await card.evaluate(element => element.style.width) + const command = card.locator('[data-command-text]').first() + const header = card.locator('[class*="_header_"]').first() + const observations: ({ colorScheme: 'light' | 'dark' } & Awaited>)[] = [] + try { + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark') + await card.evaluate((element, value) => { element.style.width = `${value}px` }, width) + observations.push({ colorScheme, ...await expectWrappedCommandText(page, command, expected, header) }) + expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await card.evaluate((element, value) => { element.style.width = value }, originalWidth) + await expectNoCommandTabStop(command) + expect(await command.textContent()).toBe(expected) + } + return observations + } finally { + await card.evaluate((element, value) => { element.style.width = value }, originalWidth) + await page.emulateMedia({ colorScheme: null }) + } +} + +/** + * Verify a terminal's status dot remains visible beside the first visual command line. + * @param card - Terminal card with its command banner scrolled to the start. + * @param state - Expected running or settled state. + * @returns Resolves after the dot fits inside the banner and aligns with the first line. + */ +export async function expectFirstLineTerminalState(card: Locator, state: 'ongoing' | 'done'): Promise { + const dot = card.locator('[class*="_runState_"][data-state]') + await expect.poll(() => dot.getAttribute('data-state')).toBe(state) + const geometry = await dot.evaluate((element) => { + const prompt = element.closest('[class*="_promptLine_"]') + const header = element.closest('[class*="_header_"]') + if (prompt === null || header === null) throw new Error('Terminal status has no prompt banner') + const bounds = element.getBoundingClientRect() + const viewport = header.getBoundingClientRect() + return { + visible: bounds.top >= viewport.top && bounds.bottom <= viewport.bottom + && bounds.left >= viewport.left && bounds.right <= viewport.right, + center: bounds.top + bounds.height / 2, + firstLineCenter: prompt.getBoundingClientRect().top + Number.parseFloat(getComputedStyle(prompt).lineHeight) / 2, + } + }) + expect(geometry.visible).toBe(true) + expect(geometry.center).toBeCloseTo(geometry.firstLineCenter, 1) +} + /** * Verify a resized command drops its explicit keyboard stop when all text fits. * @param container - Width owner of the command. diff --git a/apps/web/tests/user-text-secret-mask.e2e.ts b/apps/web/tests/user-text-secret-mask.e2e.ts new file mode 100644 index 0000000000..b45755e04f --- /dev/null +++ b/apps/web/tests/user-text-secret-mask.e2e.ts @@ -0,0 +1,74 @@ +/** A cold-seeded user message that carries a credential draws it masked behind its reveal control. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/user-text-secret-mask', import.meta.url)) +// Borrowed read-only: this scenario needs any settled user message, not a new recording. +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const RECORDED_PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +// Assembled at run time so the repository holds no literal in a credential format. +const KEY = ['sk', '-', '0123456789abcdef0123456789abcdef'].join('') +const PROMPT = `${KEY} is the key for this task.` + +describe('web e2e: credential mask in user text', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const raw = (await readFile(SEED, 'utf8')).replace(RECORDED_PROMPT, PROMPT) + expect(fixtureUserPrompts(raw), 'adapted seed must carry the credential prompt').toContain(PROMPT) + await seedSession(scaffold, raw, 'user-text-secret-mask') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByRole('button', { name: 'Show secret', exact: true }).waitFor() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('masks the credential in the bubble and reveals it from its own control', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-user-text-secret-mask')) + const text = page.locator('[class*="bubble"]', { has: page.locator('[data-secret]') }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'bubble.expected.md'), await text.ariaSnapshot(), MODE) + expect(await text.textContent()).not.toContain(KEY) + const run = page.locator('[data-secret]') + // The mask marks and the control share one vertical center. + const centers = await run.evaluate((box) => { + const middle = (element: Element): number => { + const rect = element.getBoundingClientRect() + return rect.top + rect.height / 2 + } + const [value, control] = [...box.children] + return { mask: middle(value!.lastElementChild!), control: middle(control!) } + }) + expect(Math.abs(centers.control - centers.mask)).toBeLessThanOrEqual(1) + const masked = { run: await run.boundingBox(), bubble: await text.boundingBox() } + await page.getByRole('button', { name: 'Show secret', exact: true }).click() + await expect.poll(() => text.textContent()).toBe(PROMPT) + // The monospace run keeps one mask character per original character, so revealing does not move the line. + expect({ run: await run.boundingBox(), bubble: await text.boundingBox() }).toEqual(masked) + await page.getByRole('button', { name: 'Hide secret', exact: true }).click() + await expect.poll(() => text.textContent()).not.toContain(KEY) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['bubble.expected.md']) + }) +}) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index d97a0bd6b9..2b11bfe72c 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -17,7 +17,7 @@ import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import { logPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -779,11 +779,10 @@ describe('web e2e: New Session after an outdated blank cache', () => { undefined, { createdAt: now - 60_000 }) const stored = await scaffold.ctx.sessionPersistence.stat(id) if (stored === undefined) throw new Error('seeded Session is missing') - // A durable log may advance after its last blank projection checkpoint. - // An unregistered Session writes only the checkpoint, leaving the persisted log intact. - const blank = scaffold.ctx.sessions.prepare(id, { - eventState: 'detached', seed: [], meta: stored.header, inheritedEventCount: SessionLogOffset(0), - }) + // A durable log may advance after its last blank projection checkpoint, + // which then records the cut before the first turn. An unregistered + // Session writes only the checkpoint, leaving the persisted log intact. + const blank = scaffold.ctx.sessions.prepare(id, { meta: stored.header }) await scaffold.ctx.sessionProjectionCache.write(blank) const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'New session regression') await workspace.attachSession(id) @@ -794,8 +793,14 @@ describe('web e2e: New Session after an outdated blank cache', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-new-session-stale-blank')) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor() const selected = page.locator('[role="treeitem"][aria-selected="true"]') + // Startup asks for a new conversation with the stale blank as its reuse + // candidate; the Host declines the started Session and creates a blank one. + await expect.poll(() => selected.innerText()).toBe('New Session') + await expect.poll(() => workspace.sessionIds.length).toBe(2) + // The declined Session's projections are reloaded, so its stale blank row reappears. + await page.getByRole('treeitem', { name: /^Use the read tool twice/ }).click() + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor() await expect.poll(() => selected.innerText()).not.toBe('New Session') const previous = (await selected.innerText()).split('\n')[0]! const before = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) @@ -803,7 +808,8 @@ describe('web e2e: New Session after an outdated blank cache', () => { await page.getByRole('button', { name: 'New session', exact: true }).last().click() await expect.poll(() => selected.innerText()).toBe('New Session') await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ state: 'hidden' }) - await expect.poll(() => workspace.sessionIds.length).toBe(2) + // New Session creates a fresh Session; the blank startup created stays a member. + await expect.poll(() => workspace.sessionIds.length).toBe(3) expect(workspace.sessionIds).toContain(id) await page.getByRole('treeitem').filter({ hasText: previous }).waitFor() const after = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/worktree-opt-in.e2e.ts b/apps/web/tests/worktree-opt-in.e2e.ts index c2f2e186a8..40d4e58857 100644 --- a/apps/web/tests/worktree-opt-in.e2e.ts +++ b/apps/web/tests/worktree-opt-in.e2e.ts @@ -65,10 +65,11 @@ it('persists the Git Worktrees switch and removes its capability while retaining await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() const panel = page.locator('[data-plugin-panel]') await panel.getByRole('heading', { name: 'Plugins', exact: true }).waitFor() + await panel.getByRole('button', { name: 'More', exact: true }).click() return panel } let panel = await openPlugins() - let card = panel.locator(`[data-plugin-group="official"] [data-plugin-package="${BUNDLE}"]`) + let card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`) await card.getByRole('button', { name: 'View Git Worktrees', exact: true }).waitFor() expect(await card.getByText('Experimental', { exact: true }).count()).toBe(1) let toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true }) @@ -90,7 +91,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining await page.reload({ waitUntil: 'load' }) panel = await openPlugins() - card = panel.locator(`[data-plugin-package="${BUNDLE}"]`) + card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`) toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true }) await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') expect(tools()).toContain('create_worktree') diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 295c9d1c56..04f42721a5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -22,6 +22,9 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ + "tests/collapse-timing.e2e.ts", + "tests/notice-wake-tail.e2e.ts", + "tests/chat-flow-layout.e2e.ts", "tests/layout-bottom.e2e.ts", "tests/shortcuts-desktop.e2e.ts", "tests/shortcuts-workspace.e2e.ts", @@ -112,7 +115,7 @@ "tests/desktop-onboarding.e2e.ts", "tests/bonus-notice.e2e.ts", "tests/onboarding-usable-provider.e2e.ts", - "tests/remote-welcome.e2e.ts", + "tests/remote-settings.e2e.ts", "tests/public-mount.e2e.ts", "tests/workspace-new-session-folding.e2e.ts", "tests/workspace-management.e2e.ts", @@ -133,6 +136,7 @@ "tests/present.e2e.ts", "tests/present-svg.e2e.ts", "tests/composer-draft-scroll.e2e.ts", + "tests/composer-focus.e2e.ts", "tests/composer-placeholder.e2e.ts", "tests/context-meter.e2e.ts", "tests/cordis-tool-round.e2e.ts", @@ -144,6 +148,7 @@ "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", "tests/markdown-file-links.e2e.ts", + "tests/user-text-secret-mask.e2e.ts", "tests/reference-composer.e2e.ts", "tests/markdown-wide-table.e2e.ts", "tests/thinking-markdown.e2e.ts", @@ -155,6 +160,7 @@ "tests/idle-submission-handoff.e2e.ts", "tests/cold-blank-session.e2e.ts", "tests/blank-writer-reuse.e2e.ts", + "tests/blank-preset-reuse.e2e.ts", "tests/queue-image.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/skill-user-invoke.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 010e4307f7..d0463af079 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -6,6 +6,7 @@ import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts' import { productWebBundleIsolation } from './product-isolation.ts' +import { katexWoff2Fonts } from './katex-fonts.ts' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' @@ -149,7 +150,7 @@ const BOOT_GRAMMAR_FILES: readonly string[] = [ 'dist/json.mjs', ] -/** Font asset extensions routed to assets/fonts/ (KaTeX's woff2/woff/ttf faces). */ +/** Font asset extensions routed to assets/fonts/. */ const FONT_EXTENSIONS: readonly string[] = ['.woff2', '.woff', '.ttf'] /** @@ -170,7 +171,7 @@ export default defineConfig({ // directory, and the served index resolves identically from the site root. base: './', plugins: [ - rejectStandaloneServe(), clientDocumentTitle(), brandFontLicense(), react(), emitPreviewPage(), + rejectStandaloneServe(), clientDocumentTitle(), brandFontLicense(), katexWoff2Fonts(), react(), emitPreviewPage(), productWebBundleIsolation(src('../..'), src('.')), ], build: { diff --git a/benchmarks/package.json b/benchmarks/package.json index 68ce69fa23..4a2be7ed1d 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -19,6 +19,7 @@ "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-lazy-require": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/benchmarks/support/built-worker.ts b/benchmarks/support/built-worker.ts index 6e91fde0df..8167cc9a4f 100644 --- a/benchmarks/support/built-worker.ts +++ b/benchmarks/support/built-worker.ts @@ -18,6 +18,8 @@ export interface BuiltBenchmarkWorkerOptions { readonly timeoutMs: number readonly exposeGc?: boolean readonly heapLimitMb?: number + /** Fixed semi-space size, so an allocation interval smaller than it runs without a scavenge. */ + readonly semiSpaceMb?: number } /** @@ -40,6 +42,9 @@ export function runBuiltBenchmarkWorker( ...options.heapLimitMb === undefined ? [] : [`--max-old-space-size=${String(options.heapLimitMb)}`], + ...options.semiSpaceMb === undefined + ? [] + : [`--min-semi-space-size=${String(options.semiSpaceMb)}`, `--max-semi-space-size=${String(options.semiSpaceMb)}`], options.worker, ...options.args ?? [], ], { diff --git a/benchmarks/tsdown.config.ts b/benchmarks/tsdown.config.ts index 36c4386865..cb91a1a830 100644 --- a/benchmarks/tsdown.config.ts +++ b/benchmarks/tsdown.config.ts @@ -66,4 +66,11 @@ export default defineConfig([ clean: true, tsconfig: 'tsconfig.client.json', }, + { + ...shared, + entry: { 'user-text.worker': 'user-text/user-text.worker.client.ts' }, + outDir: '.dsh-build/user-text', + clean: true, + tsconfig: 'tsconfig.client.json', + }, ]) diff --git a/benchmarks/user-text/user-text.bench.client.ts b/benchmarks/user-text/user-text.bench.client.ts new file mode 100644 index 0000000000..9377281819 --- /dev/null +++ b/benchmarks/user-text/user-text.bench.client.ts @@ -0,0 +1,161 @@ +/** Required time and allocation budgets for sent-user-text evaluation with the credential matcher. */ + +import { join } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { runBuiltBenchmarkWorker } from '../support/built-worker.ts' +import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts' +import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts' +import type { + UserTextAllocationReport, UserTextTimeReport, UserTextWorkloadAllocation, UserTextWorkloadTiming, +} from './user-text.worker.client.ts' + +/** Fresh registries and documents per sample in one compiled worker; the fastest sample removes scheduler delay. */ +const ATTEMPTS = 5 +/** A stuck worker is reaped before the outer benchmark deadline. */ +const WORKER_TIMEOUT_MS = 180_000 +/** Larger than every workload's allocation, so the allocation interval runs without a scavenge. */ +const ALLOCATION_SEMI_SPACE_MB = 256 +const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'user-text', 'user-text.worker.js') + +/** + * Documents without credentials: reference-machine milliseconds with the matcher, and the + * ratio to the shipped rules without it. Ratios do not depend on machine speed. The matcher + * adds one native literal scan, about 2 ns per character, plus format checks where a literal + * occurs (`://`, `sk-` in identifiers); 5,000 messages also pay the scheduler's cost for one + * more matcher. Running sixteen format patterns from every query offset measures 1.6–4.9×. + */ +const UNMATCHED_EXPECTED = { + 'typical-messages': { ms: 28, ratio: 1.25 }, + 'prose-256k': { ms: 1.25, ratio: 1.8 }, + 'code-256k': { ms: 1.7, ratio: 2.3 }, + 'base64-256k': { ms: 1.4, ratio: 2.05 }, + 'markdown-256k': { ms: 1.6, ratio: 2.15 }, + 'log-256k': { ms: 1.75, ratio: 2.05 }, + 'json-256k': { ms: 2.05, ratio: 2.75 }, + 'shell-256k': { ms: 3.8, ratio: 1.3 }, +} as const +/** Reference-machine milliseconds with the matcher for documents whose credentials it masks. */ +const MATCHED_EXPECTED_MS = { + 'messages-with-model-key': 28, + 'messages-with-github-token': 28, + 'messages-with-cloud-key-id': 28, + 'messages-with-json-web-token': 29, + 'messages-with-address-password': 31, + 'messages-with-private-key-block': 45, + 'env-keys-2000': 2.1, +} as const +/** + * 128 KiB shaped against one format's prefix, length, or separator rules. Format patterns + * that backtrack, or a per-query memo whose digit search reaches the end of the text from + * every query, measure 60–720 ms and up to 4× per doubling. + */ +const ADVERSARIAL_EXPECTED_MS = 3.25 +/** Doubling the input doubles a linear scan. */ +const MAX_DOUBLING_SCALING = 2 * PERFORMANCE_BUDGET_HEADROOM + +/** Measurement floor: heap statistics objects created while reading the young generation. */ +const ALLOCATION_FLOOR_BYTES = 16 * 1024 +/** + * One credential node: the node and data objects, the visible-prefix slice, and the + * concatenation with a slice of a shared mark string; measured at about 180 bytes. + */ +const ALLOCATION_PER_CREDENTIAL_BYTES = 256 +/** A multi-line mask adds one slice of a shared mark string and one concatenation per line; measured at about 60 bytes. */ +const ALLOCATION_PER_MASKED_LINE_BREAK_BYTES = 64 +/** A slice shorter than a sliced string copies its characters: at most two bytes per masked character. */ +const ALLOCATION_PER_MASKED_CHARACTER_BYTES = 2 + +const ADVERSARIAL = [ + 'digitless-sk', 'jwt-header-runs', 'jwt-two-segments', 'unterminated-key-blocks', 'address-without-at', + 'colon-run', 'long-scheme-user', 'short-token-runs', 'tokens-between-digitless-keys', 'closed-one-line-key-blocks', +] as const + +function benchmarkCase(id: string, measures: string): BenchmarkCase { + return { id: `user-text/${id}`, measures, affects: 'Rendering sent user messages, queued messages, and goal input in the Web GUI.' } +} + +async function worker(args: readonly string[], semiSpaceMb?: number): Promise { + const run = await runBuiltBenchmarkWorker({ + worker: WORKER, args, timeoutMs: WORKER_TIMEOUT_MS, exposeGc: true, ...semiSpaceMb === undefined ? {} : { semiSpaceMb }, + }) + if (run.report === undefined) { + throw new Error(`user-text worker failed: exit=${String(run.exitCode)}, signal=${String(run.signal)}, ` + + `timedOut=${String(run.timedOut)}\n${run.stderr.trim().split('\n').slice(-10).join('\n')}`) + } + return run.report +} + +describe('sent user text with the credential matcher', () => { + let timings: ReadonlyMap + let allocations: ReadonlyMap + + beforeAll(async () => { + const time = await worker(['time', String(ATTEMPTS)]) + const allocation = await worker(['allocation'], ALLOCATION_SEMI_SPACE_MB) + console.log(JSON.stringify({ benchmark: 'user-text', ...time, allocation: allocation.workloads })) + timings = new Map(time.workloads.map(workload => [workload.id, workload])) + allocations = new Map(allocation.workloads.map(workload => [workload.id, workload])) + }, 2 * WORKER_TIMEOUT_MS) + + const timing = (id: string): UserTextWorkloadTiming => { + const found = timings.get(id) + if (found === undefined) throw new Error(`user-text worker reported no ${id} workload`) + return found + } + const record = (benchmark: BenchmarkCase, measured: UserTextWorkloadTiming, budgetMs: number): void => { + recordTimings(benchmark, { withSecretMs: { ms: measured.withSecretMs, cpuMs: measured.withSecretCpuMs } }, { withSecretMs: budgetMs }) + } + + it.each(Object.entries(UNMATCHED_EXPECTED))('evaluates %s without credentials within its budget and ratio', (id, expected) => { + const measured = timing(id) + const budgetMs = ciTimeBudget(expected.ms) + record(benchmarkCase(id, `Fresh ${id} documents without credentials through every shipped matcher.`), measured, budgetMs) + expect(measured.group).toBe('unmatched') + expect(measured.secrets).toBe(0) + expect(measured.withSecretMs).toBeLessThanOrEqual(budgetMs) + expect(measured.ratio).toBeLessThanOrEqual(expected.ratio * PERFORMANCE_BUDGET_HEADROOM) + }) + + it.each(Object.entries(MATCHED_EXPECTED_MS))('masks every credential of %s within its budget', (id, expectedMs) => { + const measured = timing(id) + const budgetMs = ciTimeBudget(expectedMs) + record(benchmarkCase(id, `Fresh ${id} documents, each credential masked, through every shipped matcher.`), measured, budgetMs) + expect(measured.group).toBe('matched') + expect(measured.secrets).toBe(id === 'env-keys-2000' ? 2_000 : 5_000) + expect(measured.withSecretMs).toBeLessThanOrEqual(budgetMs) + }) + + it('masks 2,000 pasted keys in time linear in their number', () => { + const half = timing('env-keys-1000') + const full = timing('env-keys-2000') + expect([half.secrets, full.secrets]).toEqual([1_000, 2_000]) + expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + }) + + it.each(ADVERSARIAL)('scans adversarial %s input linearly without backtracking', (name) => { + const half = timing(`adversarial-${name}-64k`) + const full = timing(`adversarial-${name}-128k`) + const budgetMs = ciTimeBudget(ADVERSARIAL_EXPECTED_MS) + record(benchmarkCase(`adversarial-${name}`, `One 128 KiB document repeating a ${name} unit.`), full, budgetMs) + expect(full.withSecretMs).toBeLessThanOrEqual(budgetMs) + expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + }) + + it.each(workloadIds())('allocates only result nodes and masks for %s', (id) => { + const allocation = allocations.get(id) + if (allocation === undefined) throw new Error(`user-text worker reported no ${id} allocation`) + expect(allocation.secrets).toBe(timing(id).secrets) + const budgetBytes = ALLOCATION_FLOOR_BYTES + ALLOCATION_PER_CREDENTIAL_BYTES * allocation.secrets + + ALLOCATION_PER_MASKED_LINE_BREAK_BYTES * allocation.maskedLineBreaks + + ALLOCATION_PER_MASKED_CHARACTER_BYTES * allocation.maskedCharacters + expect(allocation.ruleAllocatedBytes).toBeLessThanOrEqual(budgetBytes) + }) +}) + +/** Every workload id, in the worker's order, so each has an allocation case. */ +function workloadIds(): readonly string[] { + return [ + ...Object.keys(UNMATCHED_EXPECTED), ...Object.keys(MATCHED_EXPECTED_MS), 'env-keys-1000', + ...ADVERSARIAL.flatMap(name => [`adversarial-${name}-64k`, `adversarial-${name}-128k`]), + ] +} diff --git a/benchmarks/user-text/user-text.worker.client.ts b/benchmarks/user-text/user-text.worker.client.ts new file mode 100644 index 0000000000..ca7ab3c099 --- /dev/null +++ b/benchmarks/user-text/user-text.worker.client.ts @@ -0,0 +1,338 @@ +/** Compiled worker for sent-user-text evaluation cost and allocation with and without the credential matcher. */ + +import { PerformanceObserver, performance } from 'node:perf_hooks' +import { getHeapSpaceStatistics } from 'node:v8' +import { Context } from '@deepseek-ai/cordis' +import type { UserTextFacts, UserTextRule, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client' +// These Client-only UserText modules have no plain-Node package export and are compiled into this worker. +import { UserTextRegistry } from '../../packages/client/ui-conversation/src/client/conversation/user-text-registry.ts' +import { sessionReferenceRule } from '../../packages/client/ui-conversation/src/client/user-text/session-reference-rule.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from '../../packages/client/ui-conversation/src/client/user-text/whitespace-rules.ts' +import { fileReferenceRule, folderReferenceRule } from '../../packages/client/ui-reference/src/client/user-text-rules.ts' +import { skillReferenceRule } from '../../packages/client/ui-skill/src/client/user-text-rules.ts' +import { goalCommandReferenceRule } from '../../packages/client/ui-goal/src/client/user-text-rules.ts' +import { createSecretRule } from '../../packages/client/ui-secret-mask/src/client/secret-rule.ts' +import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' + +/** What a workload contains: no credential, real credentials, or input shaped against one format. */ +export type UserTextWorkloadGroup = 'unmatched' | 'matched' | 'adversarial' + +/** Timing of one workload evaluated by the shipped rule set with and without the credential matcher. */ +export interface UserTextWorkloadTiming { + readonly id: string + readonly group: UserTextWorkloadGroup + readonly documents: number + readonly characters: number + readonly secrets: number + /** Characters inside credential ranges, whose masks the matcher builds. */ + readonly maskedCharacters: number + readonly baseMs: number + readonly withSecretMs: number + /** Process CPU time over the fastest with-credential sample. */ + readonly withSecretCpuMs: number + readonly ratio: number +} + +/** Heap allocation of the credential matcher over one workload, with the size of what it returns. */ +export interface UserTextWorkloadAllocation { + readonly id: string + /** Young-generation bytes the matcher allocates answering every query of every document, nodes included; no GC ran meanwhile. */ + readonly ruleAllocatedBytes: number + readonly secrets: number + /** Characters inside credential ranges. */ + readonly maskedCharacters: number + /** Line breaks inside credential ranges, each of which ends one piece of a multi-line mask. */ + readonly maskedLineBreaks: number +} + +/** Result of `time `: the fastest of `attempts` fresh samples per workload. */ +export interface UserTextTimeReport { + readonly attempts: number + readonly workloads: readonly UserTextWorkloadTiming[] +} + +/** Result of `allocation`, which needs `--expose-gc` and a young generation larger than one evaluation. */ +export interface UserTextAllocationReport { + readonly workloads: readonly UserTextWorkloadAllocation[] +} + +/** Matchers the Web composition registers besides the credential matcher. */ +const BASE_RULES: readonly UserTextRule[] = [ + sessionReferenceRule, fileReferenceRule, folderReferenceRule, skillReferenceRule, + goalCommandReferenceRule, collapseWhitespaceRule, trimWhitespaceRule, +] +/** Facts a sent message bubble supplies. */ +const FACTS: Readonly> = { sessionLabels: ['会话一'], skillNames: ['plan', 'review'] } + +const KIB = 1024 +const PROSE = '帮我看看这个函数为什么慢,顺便把 README 也更新一下。The quick brown fox jumps over the lazy dog, 1234 times. ' +const CODE = 'const value = await fetch(`https://api.example.com/v1/items?id=${id}`); if (!value.ok) throw new Error("bad: " + value.status);\n' +const BASE64 = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw' +const MARKDOWN = [ + '## 安装与配置', '', '- 运行 `pnpm install`,然后执行 `pnpm run build`。', + '- 详见 [文档](https://example.com/docs/setup?ref=readme#install)。', '', + '| 选项 | 默认值 | 说明 |', '|---|---|---|', '| `timeout` | 30s | 请求超时 |', '| `retries` | 3 | 失败重试次数 |', '', + '```ts', "const client = createClient({ baseUrl: 'https://api.example.com/v1', timeout: 30_000 })", '```', '', '', +].join('\n') +const LOG = '2026-10-11T12:00:00.123Z INFO [worker-3] GET https://api.example.com/v1/items?id=1234&page=2 200 12ms ' + + 'trace=4bf92f3577b34da6a3ce929d0e0e4736 user=alice@example.com\n' +const JSON_LINES = '{"id":"123e4567-e89b-12d3-a456-426614174000","url":"https://cdn.example.com/assets/img_0001.png",' + + '"name":"task-runner","tags":["sk-button","api_key_name"],"created_at":"2026-10-11T12:00:00Z","size":10240},\n' +const SHELL = 'pnpm --filter @deepseek-ai/dsh-client-ui-secret-mask run test -- --coverage --reporter=verbose' + + ' && git push origin feat/user-text-secret-mask\n' +/** Ordinary message fragments: prose, references, a slash skill, a URL without credentials, and code. */ +const MESSAGE_PARTS = [ + PROSE, '参照 @src/app/main.ts 修复一下', ' /plan 先列计划', ' 见 https://example.com/docs/a?b=c#d ', + '`const total = items.reduce((a, b) => a + b, 0)`', ' 然后跑 pnpm run test -- --watch=false。', +] as const + +const fill = (unit: string, characters: number): string => unit.repeat(Math.ceil(characters / unit.length)).slice(0, characters) +const MIXED = 'a1B2c3D4e5F6g7H8i9J0' +// Samples are assembled at run time so the repository holds no literal in a credential format. +const key = (index: number): string => ['sk', '-', `${index.toString(16).padStart(8, '0')}0123456789abcdef01234567`].join('') +const CREDENTIALS = { + 'model-key': key(7), + 'github-token': ['gh', 'p_', fill(MIXED, 36)].join(''), + 'cloud-key-id': ['AK', 'IA', 'ABCDEFGHIJ234567'].join(''), + 'json-web-token': ['ey', 'J', fill(MIXED, 17), '.', 'ey', 'J', fill(MIXED, 30), '.', fill(MIXED, 43)].join(''), + 'address-password': ['postgres://admin:', 'p4ss-W0rd', '@db.internal:5432/app'].join(''), + 'private-key-block': [ + ['-----BEGIN', ' RSA PRIVATE KEY-----'].join(''), ...Array.from({ length: 10 }, () => fill(BASE64, 64)), + ['-----END', ' RSA PRIVATE KEY-----'].join(''), + ].join('\n'), +} as const + +function messages(count: number): string[] { + return Array.from({ length: count }, (_, index) => { + const parts = Array.from({ length: 4 }, (_unused, part) => MESSAGE_PARTS[(index + part * 2) % MESSAGE_PARTS.length]!) + return `${String(index)} ${parts.join('')}`.slice(0, 200) + }) +} + +/** Ordinary 200-character messages, each carrying one credential whole. */ +function credentialMessages(count: number, credential: string): string[] { + return Array.from({ length: count }, (_, index) => { + const head = `${String(index)} 帮我看看这个配置能不能用:` + const tail = `,${MESSAGE_PARTS[index % MESSAGE_PARTS.length]!}${PROSE}` + return head + credential + tail.slice(0, Math.max(0, 200 - head.length - credential.length)) + }) +} + +const envFile = (count: number): string => + Array.from({ length: count }, (_, index) => `SERVICE_${String(index)}_API_KEY=${key(index)}`).join('\n') + +/** Inputs shaped against each credential format's prefix, length, and separator rules. */ +const ADVERSARIAL_UNITS = { + 'digitless-sk': 'sk-', + 'jwt-header-runs': 'eyJabcdefgh-', + 'jwt-two-segments': 'eyJabcdefgh.eyJabcdefgh-', + 'unterminated-key-blocks': ['-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n'].join(''), + 'address-without-at': 'a://u:', + 'colon-run': ':', + 'long-scheme-user': `abcdefghijklmnop://${'u'.repeat(250)}:`, + 'short-token-runs': `ghp_${fill('a1B2c3', 35)} `, + // Closed one-line blocks, each its own credential: mask searches confined to each block keep them linear. + 'closed-one-line-key-blocks': ['-----BEGIN', ' PRIVATE KEY-----abcd', '-----END', ' PRIVATE KEY----- '].join(''), + // Each query starts after an accepted digit-free token and meets a digit-free `sk-` whose digit search reaches the end. + 'tokens-between-digitless-keys': ['-', 'sk', '-', 'gh', 'p_', fill('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 36)].join(''), +} as const + +interface Workload { + readonly id: string + readonly group: UserTextWorkloadGroup + readonly texts: readonly string[] +} + +/** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */ +function workloads(): readonly Workload[] { + return [ + { id: 'typical-messages', group: 'unmatched', texts: messages(5_000) }, + ...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL }) + .map(([name, unit]): Workload => ({ id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)] })), + ...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({ + id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), + })), + { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)] }, + { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)] }, + ...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [ + { id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)] }, + { id: `adversarial-${name}-128k`, group: 'adversarial', texts: [fill(unit, 128 * KIB)] }, + ]), + ] +} + +interface Sample { + readonly ms: number + readonly cpuMs: number + readonly secrets: number + readonly maskedCharacters: number +} + +/** Rules for one sample; a fresh credential matcher per sample carries no state from an earlier sample. */ +type RuleSet = () => readonly UserTextRule[] +const withoutSecret: RuleSet = () => BASE_RULES +const withSecret: RuleSet = () => [...BASE_RULES, createSecretRule()] + +function registryWith(rules: RuleSet): { readonly registry: UserTextRegistry; readonly scope: Context } { + const scope = new Context() + const registry = new UserTextRegistry(scope) + for (const rule of rules()) registry.register(rule) + return { registry, scope } +} + +/** + * Evaluate every text once as a fresh document, as a newly rendered message does. + * @returns elapsed and CPU milliseconds, credential nodes produced, and their characters. + */ +function evaluateAll(rules: RuleSet, texts: readonly string[]): Sample { + const { registry, scope } = registryWith(rules) + let secrets = 0 + let maskedCharacters = 0 + const cpuStart = process.cpuUsage() + const start = performance.now() + for (const text of texts) { + for (const node of registry.createEvaluator()({ text, facts: FACTS })) { + if (node.id !== 'secret') continue + secrets++ + maskedCharacters += node.end - node.start + } + } + const ms = performance.now() - start + const cpu = process.cpuUsage(cpuStart) + void scope.fiber.dispose() + return { ms, cpuMs: (cpu.user + cpu.system) / 1_000, secrets, maskedCharacters } +} + +function fastest(rules: RuleSet, texts: readonly string[], attempts: number): Sample { + let best = evaluateAll(rules, texts) + for (let attempt = 1; attempt < attempts; attempt++) { + const sample = evaluateAll(rules, texts) + if (sample.ms < best.ms) best = sample + } + return best +} + +declare const gc: (() => void) | undefined + +/** + * Young-generation bytes in use. Code and feedback that compilation allocates during a + * measurement live in other spaces, so they do not count as allocation by the evaluation. + */ +function youngBytes(): number { + let total = 0 + for (const space of getHeapSpaceStatistics()) { + if (space.space_name === 'new_space' || space.space_name === 'new_large_object_space') total += space.space_used_size + } + return total +} + +/** The credential matcher reads no Facts. */ +const NO_FACTS: UserTextTransformContext = { read: () => undefined } + +/** + * Query the credential matcher over every document as the scheduler does when no other + * matcher produces a node; an index loop, so the loop itself allocates nothing. + */ +function queryAll(rule: UserTextRule, texts: readonly string[]): void { + for (let index = 0; index < texts.length; index++) { + const text = texts[index]! + for (let from = 0; ;) { + const node = rule.match(text, from, NO_FACTS) + if (node === undefined) break + from = node.end + } + } +} + +/** Credential nodes the matcher returns over every document, counted outside any measured interval. */ +function returned(texts: readonly string[]): Omit { + const rule = createSecretRule() + let secrets = 0 + let maskedCharacters = 0 + let maskedLineBreaks = 0 + for (const text of texts) { + for (let node = rule.match(text, 0, NO_FACTS); node !== undefined; node = rule.match(text, node.end, NO_FACTS)) { + secrets++ + maskedCharacters += node.end - node.start + for (let index = node.start; index < node.end; index++) { + const code = text.charCodeAt(index) + if (code === 10 || code === 13) maskedLineBreaks++ + } + } + } + return { secrets, maskedCharacters, maskedLineBreaks } +} + +/** + * Bytes one warmed pass of every query allocates, measured across an interval without GC. + * @returns young-generation growth over the interval. + */ +async function allocatedBytes(texts: readonly string[], collect: () => void): Promise { + const collections: number[] = [] + const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) collections.push(entry.startTime) }) + observer.observe({ entryTypes: ['gc'] }) + try { + const rule = createSecretRule() + for (let warm = 0; warm < 10; warm++) queryAll(rule, texts) + collect() + collect() + const start = performance.now() + const used = youngBytes() + queryAll(rule, texts) + const grown = youngBytes() - used + const end = performance.now() + // GC entries arrive on a later turn. + await new Promise(resolve => { setTimeout(resolve, 20) }) + const inside = collections.filter(time => time >= start && time <= end).length + if (inside > 0) throw new Error(`${String(inside)} garbage collections ran inside the allocation interval; enlarge the young generation`) + return grown + } finally { + observer.disconnect() + } +} + +function positiveInteger(value: string | undefined, name: string): number { + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`) + return parsed +} + +async function allocations(): Promise { + if (gc === undefined) throw new Error('user-text allocation mode needs --expose-gc') + const collect = gc + const results: UserTextWorkloadAllocation[] = [] + for (const { id, texts } of workloads()) { + results.push({ id, ruleAllocatedBytes: await allocatedBytes(texts, collect), ...returned(texts) }) + } + return { workloads: results } +} + +function timings(attempts: number): UserTextTimeReport { + return { + attempts, + workloads: workloads().map(({ id, group, texts }) => { + const base = fastest(withoutSecret, texts, attempts) + const secret = fastest(withSecret, texts, attempts) + return { + id, + group, + documents: texts.length, + characters: texts.reduce((sum, text) => sum + text.length, 0), + secrets: secret.secrets, + maskedCharacters: secret.maskedCharacters, + baseMs: base.ms, + withSecretMs: secret.ms, + withSecretCpuMs: secret.cpuMs, + ratio: secret.ms / base.ms, + } + }), + } +} + +assertBuiltBenchmarkRuntime(import.meta.url, { + '@deepseek-ai/cordis': import.meta.resolve('@deepseek-ai/cordis'), + '@deepseek-ai/dsh-client-store': import.meta.resolve('@deepseek-ai/dsh-client-store'), +}) +const [mode, count] = process.argv.slice(2) +if (mode !== 'time' && mode !== 'allocation') throw new Error('usage: user-text.worker.js time | allocation') +console.log(JSON.stringify(mode === 'time' ? timings(positiveInteger(count, 'attempts')) : await allocations())) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index d4bbea471f..39ebd5cd9c 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -23,6 +23,7 @@ Each fact has one home: the tier whose job it is; elsewhere, link there. | [architecture.md](architecture.md) | Ordered map: composition, core packages, loop, seams, extension points; read before changing `packages/` | Type definitions (→ subsystems), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations | | [subsystems/](subsystems/README.md) | One reference page per subsystem: type definitions, semantics, and the generated Cordis API | Behavior narration (→ architecture.md) | | [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and required verification; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority | +| [defensive-patterns/](defensive-patterns/README.md) | Rules this repository enforces in review, split by artifact: one general bug-class page plus per-domain pages | Type definitions and semantics (→ subsystems), decision rationale (→ Agent Notes) | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [Persistence history](persistence-changes/README.md) and [format references](persistence-changes/historical-formats/README.md) | Type-change acknowledgements, release comparisons, and complete historical format schemas | Behavior-only changes; current runtime contracts | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the Agent Note each guide links) | @@ -32,7 +33,7 @@ Each fact has one home: the tier whose job it is; elsewhere, link there. | Generated reference: the per-page `cordis-surface` regions in [subsystems/](subsystems/README.md), the [Cordis core API + inherited tier](cordis-api/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md), [cli-help](cli-help.md) | Exhaustive English sources regenerated from source and freshness-gated; reviewed Chinese counterparts follow the [pairing workflow](i18n/README.md#scope-and-exclusions) | Hand edits to generated English sources or regions; Chinese counterparts update through pairing only | | Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) | -Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type definitions → subsystems; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. +Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type definitions → subsystems; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link; review-enforced rules → `defensive-patterns/`. ## Writing rules @@ -55,7 +56,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,960; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. +Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 2,080; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 810 and this file ≤ 1,430; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. ## The slop checklist diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 1072232f44..3f9e8b79ab 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -4,7 +4,7 @@ # pnpm run verify-translation-pairing --write docs/api-gateway.md /api-gateway: en: 735eb4f22d5f6f71 - zh: 1ca2759961efdc06 + zh: 697eef269b133039 /api-gateway/programming-model: en: ea12b64211f0d377 zh: 032e5c0d799c7529 @@ -12,8 +12,8 @@ en: e29f1d7026291075 zh: 15e361e797d44fce /api-gateway/strict-generation-pipeline: - en: cb8da81a92acfa52 - zh: be5f3c268f8571df + en: 9c4a98eb0539ec7f + zh: ad2a145abd805fdd /api-gateway/runtime-invocation: en: 2a2fed340aaa926c zh: 46bb1f500c870a76 @@ -21,8 +21,11 @@ en: 9570ffe7a348e5ac zh: 2b70b61b1ff1fbb0 /api-gateway/development-mode: - en: ba090d76471dd663 - zh: fcddcb232de98248 + en: 23ae422bd65300da + zh: 67dd9c02f4ffec53 /api-gateway/boundaries: en: e6befc5f8c90f97b - zh: 3ade08102542a894 + zh: 4ace4a493fd2b41f +/api-gateway/boundaries/business-ownership: + en: 980ef04e501a3896 + zh: d2f6db14e5135b68 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 91f992dc5e..0a891cd244 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -96,7 +96,7 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p ## Strict generation pipeline -The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. +The root build compiles and bundles the Host face, then the Client face, then builds the Web shell ([step order](development.md#typescript-project-layout)). The Host phase first compiles the Host package references, then runs `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client phase then compiles the Client package references and runs `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. Both tsdown passes match `vendor/*`, `packages/*/*`, and `apps/cli`, the Host pass also `apps/desktop-host` ([build order](development.md#typescript-project-layout)), and both bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. @@ -154,7 +154,7 @@ Changing only a Remote method's implementation body without changing its contrac pnpm run build:lib ``` -The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` runs the Host lib phase before Client tsc, and CI and release builds use the same order. +The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` generates the Host contract with the bundle-free Typert pass before Client tsc, and the CI build lanes and release builds generate it in the Host bundle phase before Client tsc. ## Boundaries @@ -163,3 +163,7 @@ Remote handles unary method calls with one request and one result, and stream me The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. Unary results containing `Uint8Array`, including nested fields and array entries, use [binary Remote transfer](../.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md). A feature that needs a response outside the RPC envelope registers an exact Connection Fetch route. Lookup policy is configured per key, so all `agent` or `session` parameters share the cold-resume behavior. Accepting live objects only would require an explicit per-parameter or per-endpoint policy, which does not exist; the business method must not guess whether the object came from restoration. + +### Business ownership + +`packages/api` owns transport, call shape, and session lifecycle; a business rule belongs to an independent business package and reaches this layer through a registration surface or projected data. The requirements a change here must satisfy — the four `@Remote` disciplines, the additive-names rule, and the boundary this layer keeps — are owned by [Remote and API rules](defensive-patterns/remote-and-api.md), which is where a review checks them. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 4e21bafa9b..4cc33ee24b 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -4,6 +4,8 @@ 本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元与流式 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 + + ## 编程模型 业务服务通过 `@Remote` 或 `@RemoteScope` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 @@ -96,7 +98,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 ## 严格生成流水线 -根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 +根构建依次编译并打包 Host face、Client face,再构建 Web 壳层([步骤顺序](development.zh.md#typescript-project-layout))。Host 阶段先编译 Host 包 reference,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client 阶段随后编译 Client 包 reference 并运行 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 两次 tsdown 都匹配 `vendor/*`、`packages/*/*` 与 `apps/cli`,Host 阶段另外匹配 `apps/desktop-host`([构建顺序](development.zh.md#typescript-project-layout)),且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 @@ -154,7 +156,9 @@ pnpm run dev:web pnpm run build:lib ``` -运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 约定,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 会执行 Host lib 阶段后再运行 Client tsc,CI 与发布构建也使用同一顺序。 +运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 约定,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 先以不打包的 Typert 生成刷新 Host 约定,再运行 Client tsc;CI 的构建 lane 与发布构建则在 Client tsc 之前的 Host bundle 阶段生成它。 + + ## 边界 @@ -163,3 +167,9 @@ Remote 处理单请求单结果的一元方法调用,以及项沿 Host → Cli API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。包含 `Uint8Array` 的一元结果(包括嵌套字段和数组条目)使用[二进制 Remote 传输](../.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md)。需要 RPC 信封之外响应的功能注册精确的 Connection Fetch 路由。 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。只接受 live 对象需要显式的逐参数或逐 endpoint 策略,而这种策略并不存在;不能通过业务方法内部猜测对象是否来自恢复。 + + + +### 业务所有权 + +`packages/api` 负责传输、调用形态与会话生命周期;业务规则归独立的业务包,经注册面或投影数据接入本层。这里改动必须满足的要求——`@Remote` 的四条纪律、存量名只增不改、以及本层保留的边界——正家是[远程与 API 规则](defensive-patterns/remote-and-api.zh.md),评审就在那里核对。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 182bb9349e..ba4bb17387 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 912784044de3337d - zh: 6a03f65751cda74a + en: 2e9d4cb22f8f2a6b + zh: 81f523914e8531f7 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 7619be90f4..95ab26504b 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -272,6 +272,9 @@ flowchart LR svc_webServer["ctx.webServer
HTTP route registration"] pkg_client_modules["client-modules"] pkg_client_hmr["client-hmr"] + pkg_web_app["web-app"] + svc_webApp["ctx.webApp
Served Web GUI"] + pkg_harness_dev_context["harness-dev-context"] svc_clientModules["ctx.clientModules
Client plugin graph host"] pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] @@ -428,6 +431,7 @@ flowchart LR pkg_user_approval --> svc_approval pkg_user_questions --> svc_userQuestions pkg_web --> svc_web + pkg_web_app --> svc_webApp pkg_web_fetch_http --> svc_web pkg_web_search_deepseek --> svc_web pkg_web_search_exa --> svc_web @@ -572,6 +576,7 @@ flowchart LR svc_typert --> pkg_typert_loader svc_userQuestions --> pkg_tool_ask_user svc_web --> pkg_tool_web + svc_webApp --> pkg_harness_dev_context svc_webServer --> pkg_client_connection svc_webServer --> pkg_client_hmr svc_webServer --> pkg_client_modules @@ -682,6 +687,7 @@ flowchart LR | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | +| `ctx.webApp` | `core` | [`web-app`](../packages/bundle/web-app) | - | [`harness-dev-context`](../packages/context/harness-dev-context) | - | The web-runtime row advertises one URL for its Web GUI: the configured publicUrl, otherwise the listener origin; DSH_WEB_URL, the startup URL, and harness-dev-context read the same value. | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-ptc`](../packages/workflow/workflow-ptc) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/experimental/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/experimental/webhook) | - | [`webhook-github`](../packages/experimental/webhook-github) | - | Provider adapters dispatch authenticated deliveries; trusted plugins register independent process-local rules, and the runtime turns non-null results into ordinary Workspace-backed Sessions without delivery or completion state. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index b02ec2e41c..ee3ec60466 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -274,6 +274,9 @@ flowchart LR svc_webServer["ctx.webServer
HTTP route registration"] pkg_client_modules["client-modules"] pkg_client_hmr["client-hmr"] + pkg_web_app["web-app"] + svc_webApp["ctx.webApp
Served Web GUI"] + pkg_harness_dev_context["harness-dev-context"] svc_clientModules["ctx.clientModules
Client plugin graph host"] pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] @@ -430,6 +433,7 @@ flowchart LR pkg_user_approval --> svc_approval pkg_user_questions --> svc_userQuestions pkg_web --> svc_web + pkg_web_app --> svc_webApp pkg_web_fetch_http --> svc_web pkg_web_search_deepseek --> svc_web pkg_web_search_exa --> svc_web @@ -574,6 +578,7 @@ flowchart LR svc_typert --> pkg_typert_loader svc_userQuestions --> pkg_tool_ask_user svc_web --> pkg_tool_web + svc_webApp --> pkg_harness_dev_context svc_webServer --> pkg_client_connection svc_webServer --> pkg_client_hmr svc_webServer --> pkg_client_modules @@ -684,6 +689,7 @@ flowchart LR | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | +| `ctx.webApp` | `core` | [`web-app`](../packages/bundle/web-app) | - | [`harness-dev-context`](../packages/context/harness-dev-context) | - | web-runtime 行为其 Web GUI 公告一个 URL:配置了 publicUrl 时用它,否则用监听器 origin;DSH_WEB_URL、启动 URL 与 harness-dev-context 读取同一个值。 | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | 通过增量 `dsh.client` 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-ptc`](../packages/workflow/workflow-ptc) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/experimental/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/experimental/webhook) | - | [`webhook-github`](../packages/experimental/webhook-github) | - | 提供方适配器分派已认证交付;可信插件注册独立的进程本地规则,runtime 把非 null 结果转换为普通的 Workspace-backed Session,不保留交付或完成状态。 | diff --git a/docs/cli-help.md b/docs/cli-help.md index 4539636c4a..c14b06ac73 100644 --- a/docs/cli-help.md +++ b/docs/cli-help.md @@ -173,22 +173,31 @@ Usage: dsh --profile web [options] Serve the DeepSeek Harness browser UI. Options: - --host bind host + --host bind address: one concrete IPv4 or IPv6 literal + of a local interface; wildcard addresses are + rejected --no-open do not open the Web UI in the default browser --port listen port; pass 0 to let the OS pick a free one --public-url advertise this HTTP(S) root in the printed, - opened, web-surface, and DSH_WEB_URL forms; + opened, runtime-context, and DSH_WEB_URL forms; grants no trust --trusted-host extra authority the /api browser-trust fence accepts (host or host:port; repeatable) + --tls-cert PEM server certificate chain; requires + --tls-key + --tls-key unencrypted PEM private key; requires + --tls-cert -h, --help show this help Examples: dsh --profile web serve on the composed host and port dsh --profile web --no-open serve without opening a browser dsh --profile web --port 8080 serve on another port + dsh --profile web --host 10.0.0.7 bind one local interface address dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example advertise a prefix-stripping HTTPS proxy entry and admit its authority + dsh --profile web --tls-cert ./server-chain.pem --tls-key ./server-key.pem + serve HTTPS with an existing certificate and key ``` diff --git a/docs/cli-help.zh.md b/docs/cli-help.zh.md index f875c988e1..f646eac073 100644 --- a/docs/cli-help.zh.md +++ b/docs/cli-help.zh.md @@ -175,22 +175,31 @@ Usage: dsh --profile web [options] Serve the DeepSeek Harness browser UI. Options: - --host bind host + --host bind address: one concrete IPv4 or IPv6 literal + of a local interface; wildcard addresses are + rejected --no-open do not open the Web UI in the default browser --port listen port; pass 0 to let the OS pick a free one --public-url advertise this HTTP(S) root in the printed, - opened, web-surface, and DSH_WEB_URL forms; + opened, runtime-context, and DSH_WEB_URL forms; grants no trust --trusted-host extra authority the /api browser-trust fence accepts (host or host:port; repeatable) + --tls-cert PEM server certificate chain; requires + --tls-key + --tls-key unencrypted PEM private key; requires + --tls-cert -h, --help show this help Examples: dsh --profile web serve on the composed host and port dsh --profile web --no-open serve without opening a browser dsh --profile web --port 8080 serve on another port + dsh --profile web --host 10.0.0.7 bind one local interface address dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example advertise a prefix-stripping HTTPS proxy entry and admit its authority + dsh --profile web --tls-cert ./server-chain.pem --tls-key ./server-key.pem + serve HTTPS with an existing certificate and key ``` diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 775ece7747..78fae94923 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -220,7 +220,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -444,11 +444,9 @@ export interface ConnectionConfig { recovery?: ConnectionRecoveryConfig /** * Authorities this deployment serves beyond loopback: exact `host:port`, or - * port-less `host` matching any port. The /api trust fence refuses any - * request whose Host is neither loopback nor listed here, so a - * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by; the Web runtime derives LAN IP literals from an active all-interface - * bind. An entry that is not a bare, canonical authority fails plugin load. + * port-less `host` matching any port. The fence accepts loopback and the + * listener's own bind IP independently; other remote authorities require + * an entry here. A non-canonical or non-bare authority fails plugin load. */ trustedHosts?: string[] /** Absolute browser-session lifetime in days. Default: 30. */ @@ -1220,6 +1218,8 @@ export interface Config { minSpeechSeconds: number /** Silence separating two speech segments. */ minSilenceSeconds: number + /** Recording audio restored ahead of the first VAD segment, which cold start reports late; zero disables it. */ + vadOnsetPaddingSeconds: number /** Maximum decoded WAV bytes accepted by the private worker. */ maxAudioBytes: number /** Deadline for runtime preparation and cold model loading. */ @@ -1315,7 +1315,7 @@ export interface Config { directory?: string /** Prefix for automatically generated branch and checkout names. */ namePrefix?: string - /** Git 2.45 or newer executable name or absolute execution-world path. */ + /** Executable name or absolute execution-world path of a Git release that honors `GIT_NO_LAZY_FETCH`. */ gitCommand?: string /** Node executable used for sandboxed directory allocation. */ nodeCommand?: string @@ -1626,15 +1626,32 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` -- `source`: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts) +- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts) ```ts config-catalog -/** Web server listen and response-compression config. */ +/** Web server listen, TLS, and response-compression config. */ export interface Config { - /** Listen host; the two supported values are loopback and all-interfaces. */ - host: '127.0.0.1' | '0.0.0.0' + /** + * Listen address: a concrete IPv4 or IPv6 literal of one local interface, + * for example the container's own Pod address from `hostname -i`. A loopback + * literal (any address in 127/8, `::1`, or a mapped form of either) keeps the + * server on this machine; any other literal serves the network that address + * belongs to, over plain HTTP unless `tls` is set. The unspecified address — + * IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at + * load: it would expose the port on every interface at once. + */ + host: string /** Listen port; zero requests an OS-assigned port. */ port: number + /** + * Serve HTTPS with this certificate and key instead of plain HTTP. Both files + * are read once, before the listener binds: an unreadable or empty file, + * invalid PEM, or a key that does not match the certificate rejects + * initialization rather than falling back to HTTP. The material is never + * re-read, so replacing a certificate takes a reload. Omitted or null listens + * over plain HTTP. + */ + tls?: TlsConfig /** Response compression for socket-backed HTTP requests. @default 'none' */ compression?: 'none' | 'gzip' /** Gzip DEFLATE level from 0 through 9. @default 1 */ @@ -1642,6 +1659,18 @@ export interface Config { /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */ compressionThresholdBytes?: number } + +/** + * TLS material for the HTTPS listener: one certificate chain file and the + * private key file it pairs with. Both hold PEM text and both resolve against + * the process working directory. + */ +export interface TlsConfig { + /** Certificate chain file, leaf certificate first, PEM, no passphrase. */ + certFile: string + /** Private key file for the chain's leaf certificate; unencrypted PEM. */ + keyFile: string +} ``` @@ -2412,11 +2441,15 @@ export interface Config { ## `@deepseek-ai/dsh-ptc-runtime-node` - `inject`: `fs` · `subprocess` · `sandbox` · `sandboxPolicy` -- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:26`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts) +- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/config.ts:7`](../packages/ptc-runtime/ptc-runtime-node/src/config.ts) ```ts config-catalog /** Deployment-varying runtime bounds and launch choices. */ -export interface Config extends LaunchConfig { +export interface Config { + /** Worker invocation in the subprocess world; omitted selects the local carrier. */ + launch?: LaunchConfig + /** per-call starts fresh processes; persistent-in-memory retains a REPL per owner under the same file policy. */ + executionMode?: 'per-call' | 'persistent-in-memory' /** Default elapsed deadline, including nested tool and approval waits. */ timeoutMs?: number /** Maximum numeric elapsed budget accepted by resolve. */ @@ -2433,12 +2466,19 @@ export interface Config extends LaunchConfig { graceMs?: number } -/** Deployment-owned Node executable and optional preinstalled built bootstrap. */ -export interface LaunchConfig { - /** Executable in the subprocess world; defaults to the current Node executable. */ - nodeExecutable?: string - /** Absolute preinstalled built bootstrap in the execution world. */ +/** Deployment-owned worker invocation in the subprocess execution world. */ +export type LaunchConfig = { + /** Start a separately installed Node executable and JavaScript bootstrap. */ + kind: 'node-script' + /** Executable name or path resolved by the subprocess provider. */ + executable: string + /** Absolute preinstalled bootstrap; omitted maps this package's bootstrap into the execution world. */ bootstrapPath?: string +} | { + /** Start the private PTC worker embedded in a packaged executable. */ + kind: 'embedded' + /** Packaged executable name or path resolved by the subprocess provider. */ + executable: string } ``` @@ -2907,11 +2947,16 @@ export interface Config { - `inject`: `sessionTitle` · `llm` · `sessions` - `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts) -- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:15`](../packages/experimental/session-title-all-prompts-llm/src/index.ts) +- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:28`](../packages/experimental/session-title-all-prompts-llm/src/index.ts) ```ts config-catalog -/** Required LLM policy; this plugin adds no defaults. */ -export type Config = SessionTitleLlmConfig +/** Required execution controls plus this provider's title-length targets; no defaults. */ +export interface Config extends SessionTitleLlmConfig { + /** Target word count for non-CJK titles. */ + readonly targetWords: number + /** Target character count for Chinese, Japanese, or Korean titles. */ + readonly targetCjkCharacters: number +} ``` @@ -2922,11 +2967,16 @@ export type Config = SessionTitleLlmConfig - `inject`: `sessionTitle` · `llm` · `sessions` - `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts) -- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:15`](../packages/session/session-title-first-prompt-llm/src/index.ts) +- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:28`](../packages/session/session-title-first-prompt-llm/src/index.ts) ```ts config-catalog -/** Required LLM policy; this plugin adds no defaults. */ -export type Config = SessionTitleLlmConfig +/** Required execution controls plus this provider's title-length targets; no defaults. */ +export interface Config extends SessionTitleLlmConfig { + /** Target word count for non-CJK titles. */ + readonly targetWords: number + /** Target character count for Chinese, Japanese, or Korean titles. */ + readonly targetCjkCharacters: number +} ``` @@ -3012,7 +3062,7 @@ export interface Config { ```ts config-catalog /** Office skill resource location. */ export interface Config { - /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ + /** Absolute assets directory containing skill folders and shared scripts; defaults to packaged assets. */ assetRoot?: string /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ node?: string @@ -3075,25 +3125,21 @@ export interface Config { ## `@deepseek-ai/dsh-ssh` -- `source`: [`packages/ssh/ssh/src/index.ts:17`](../packages/ssh/ssh/src/index.ts) +- `source`: [`packages/ssh/ssh/src/index.ts:33`](../packages/ssh/ssh/src/index.ts) ```ts config-catalog /** Deployment-owned SSH identity and installed helper; no model argument selects these values. */ export interface Config { /** OpenSSH host alias, including its existing user, key and known-host configuration. */ host: string - /** Absolute remote Node executable. */ - node: string + /** Explicit script or self-contained executable invocation. */ + launch: HelperLaunch /** Absolute path to the installed, bundled helper entry. */ helper: string /** SHA-256 of that bundled helper; mismatches refuse the connection. */ helperHash: string /** Absolute remote default workspace. */ workspace: string - /** Optional preinstalled built PTC entry, paired with its expected digest. */ - bootstrapPath?: string - /** SHA-256 of bootstrapPath; both fields must be supplied together. */ - bootstrapHash?: string /** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */ requestTimeoutMs?: number /** Maximum JSON payload bytes per helper request or response. */ @@ -3103,6 +3149,21 @@ export interface Config { /** Remote helper lease; loss of heartbeats starts remote managed cleanup. */ leaseMs?: number } + +/** Installed helper invocation; script deployments may also install a PTC bootstrap. */ +export type HelperLaunch = { + /** Run the installed script with a separately installed Node executable. */ + kind: 'node-script' + /** Absolute remote Node executable. */ + node: string + /** Absolute remote PTC bootstrap; requires bootstrapHash. */ + bootstrapPath?: string + /** Lowercase SHA-256 of bootstrapPath; requires that path. */ + bootstrapHash?: string +} | { + /** Run the helper executable with its embedded Node and PTC worker. */ + kind: 'executable' +} ``` @@ -3416,7 +3477,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:254`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:257`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -4205,7 +4266,7 @@ export interface WebRuntimeConfig { ## `@deepseek-ai/dsh-web-app` - `inject`: `webServer` -- `source`: [`packages/bundle/web-app/src/index.ts:46`](../packages/bundle/web-app/src/index.ts) +- `source`: [`packages/bundle/web-app/src/index.ts:55`](../packages/bundle/web-app/src/index.ts) ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ @@ -4215,22 +4276,18 @@ export interface Config { /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** - * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive - * layer can turn it off when its user is not in the GUI, so the - * orientation text would be false. + * Register the `DSH_WEB_URL` bash variable naming this GUI. A one-shot + * non-interactive layer can turn it off when its user is not in the GUI. */ surfaceContext: boolean /** * Canonical HTTP(S) root to advertise in the printed and opened URL, - * `DSH_WEB_URL`, and the web-surface orientation, e.g. - * `https://app.example/ui/`, normalized to end in `/`. Advertisement only; - * see [public deployments](../README.md#public-deployments). Absent or YAML - * `null` advertises the loopback URL. + * `DSH_WEB_URL`, and `webApp.getAdvertisedUrl()`, which supplies the Web GUI + * URL in agent runtime context, e.g. `https://app.example/ui/`, normalized to end in `/`. Configures no routing + * or authentication; see [public deployments](../README.md#public-deployments). + * Absent or YAML `null` advertises the bind-address URL. */ publicUrl?: string - /** Explicit `--trusted-host` authorities from this invocation. */ - trustedHosts: string[] } ``` @@ -4473,6 +4530,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-reference` | — | [`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts) | | `@deepseek-ai/dsh-client-ui-renderer` | — | [`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts) | | `@deepseek-ai/dsh-client-ui-schedule` | — | [`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-secret-mask` | — | [`packages/client/ui-secret-mask/src/index.ts`](../packages/client/ui-secret-mask/src/index.ts) | | `@deepseek-ai/dsh-client-ui-session` | — | [`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings` | — | [`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings-agent-loop` | — | [`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts) | @@ -4515,7 +4573,8 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) | | `@deepseek-ai/dsh-fs-ssh` | `ssh` · `sandboxPolicy` | [`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts) | | `@deepseek-ai/dsh-goal-round-driver` | `agents` · `goals` · `sessions` | [`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts) | -| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) | +| `@deepseek-ai/dsh-harness-dev-context` | `systemPrompt` · `sessionProjections` · `webApp` | [`packages/context/harness-dev-context/src/index.ts`](../packages/context/harness-dev-context/src/index.ts) | +| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `connection` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) | | `@deepseek-ai/dsh-host-directory-picker-native` | — | [`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts) | | `@deepseek-ai/dsh-host-plugin-inventory` | `loader` | [`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts) | | `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) | @@ -4629,6 +4688,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-session-snapshot` | — | [`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts) | | `@deepseek-ai/dsh-session-telemetry` | — | [`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts) | | `@deepseek-ai/dsh-session-title-llm` | — | [`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts) | +| `@deepseek-ai/dsh-ssh-helper-runtime` | — | [`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts) | | `@deepseek-ai/dsh-timeout` | — | [`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts) | | `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) | | `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 370b740e74..a8d29108d5 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -222,7 +222,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -446,11 +446,9 @@ export interface ConnectionConfig { recovery?: ConnectionRecoveryConfig /** * Authorities this deployment serves beyond loopback: exact `host:port`, or - * port-less `host` matching any port. The /api trust fence refuses any - * request whose Host is neither loopback nor listed here, so a - * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by; the Web runtime derives LAN IP literals from an active all-interface - * bind. An entry that is not a bare, canonical authority fails plugin load. + * port-less `host` matching any port. The fence accepts loopback and the + * listener's own bind IP independently; other remote authorities require + * an entry here. A non-canonical or non-bare authority fails plugin load. */ trustedHosts?: string[] /** Absolute browser-session lifetime in days. Default: 30. */ @@ -1222,6 +1220,8 @@ export interface Config { minSpeechSeconds: number /** Silence separating two speech segments. */ minSilenceSeconds: number + /** Recording audio restored ahead of the first VAD segment, which cold start reports late; zero disables it. */ + vadOnsetPaddingSeconds: number /** Maximum decoded WAV bytes accepted by the private worker. */ maxAudioBytes: number /** Deadline for runtime preparation and cold model loading. */ @@ -1317,7 +1317,7 @@ export interface Config { directory?: string /** Prefix for automatically generated branch and checkout names. */ namePrefix?: string - /** Git 2.45 or newer executable name or absolute execution-world path. */ + /** Executable name or absolute execution-world path of a Git release that honors `GIT_NO_LAZY_FETCH`. */ gitCommand?: string /** Node executable used for sandboxed directory allocation. */ nodeCommand?: string @@ -1628,15 +1628,32 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` -- `source`: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts) +- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts) ```ts config-catalog -/** Web server listen and response-compression config. */ +/** Web server listen, TLS, and response-compression config. */ export interface Config { - /** Listen host; the two supported values are loopback and all-interfaces. */ - host: '127.0.0.1' | '0.0.0.0' + /** + * Listen address: a concrete IPv4 or IPv6 literal of one local interface, + * for example the container's own Pod address from `hostname -i`. A loopback + * literal (any address in 127/8, `::1`, or a mapped form of either) keeps the + * server on this machine; any other literal serves the network that address + * belongs to, over plain HTTP unless `tls` is set. The unspecified address — + * IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at + * load: it would expose the port on every interface at once. + */ + host: string /** Listen port; zero requests an OS-assigned port. */ port: number + /** + * Serve HTTPS with this certificate and key instead of plain HTTP. Both files + * are read once, before the listener binds: an unreadable or empty file, + * invalid PEM, or a key that does not match the certificate rejects + * initialization rather than falling back to HTTP. The material is never + * re-read, so replacing a certificate takes a reload. Omitted or null listens + * over plain HTTP. + */ + tls?: TlsConfig /** Response compression for socket-backed HTTP requests. @default 'none' */ compression?: 'none' | 'gzip' /** Gzip DEFLATE level from 0 through 9. @default 1 */ @@ -1644,6 +1661,18 @@ export interface Config { /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */ compressionThresholdBytes?: number } + +/** + * TLS material for the HTTPS listener: one certificate chain file and the + * private key file it pairs with. Both hold PEM text and both resolve against + * the process working directory. + */ +export interface TlsConfig { + /** Certificate chain file, leaf certificate first, PEM, no passphrase. */ + certFile: string + /** Private key file for the chain's leaf certificate; unencrypted PEM. */ + keyFile: string +} ``` @@ -2414,11 +2443,15 @@ export interface Config { ## `@deepseek-ai/dsh-ptc-runtime-node` - `inject`: `fs` · `subprocess` · `sandbox` · `sandboxPolicy` -- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:26`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts) +- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/config.ts:7`](../packages/ptc-runtime/ptc-runtime-node/src/config.ts) ```ts config-catalog /** Deployment-varying runtime bounds and launch choices. */ -export interface Config extends LaunchConfig { +export interface Config { + /** Worker invocation in the subprocess world; omitted selects the local carrier. */ + launch?: LaunchConfig + /** per-call starts fresh processes; persistent-in-memory retains a REPL per owner under the same file policy. */ + executionMode?: 'per-call' | 'persistent-in-memory' /** Default elapsed deadline, including nested tool and approval waits. */ timeoutMs?: number /** Maximum numeric elapsed budget accepted by resolve. */ @@ -2435,12 +2468,19 @@ export interface Config extends LaunchConfig { graceMs?: number } -/** Deployment-owned Node executable and optional preinstalled built bootstrap. */ -export interface LaunchConfig { - /** Executable in the subprocess world; defaults to the current Node executable. */ - nodeExecutable?: string - /** Absolute preinstalled built bootstrap in the execution world. */ +/** Deployment-owned worker invocation in the subprocess execution world. */ +export type LaunchConfig = { + /** Start a separately installed Node executable and JavaScript bootstrap. */ + kind: 'node-script' + /** Executable name or path resolved by the subprocess provider. */ + executable: string + /** Absolute preinstalled bootstrap; omitted maps this package's bootstrap into the execution world. */ bootstrapPath?: string +} | { + /** Start the private PTC worker embedded in a packaged executable. */ + kind: 'embedded' + /** Packaged executable name or path resolved by the subprocess provider. */ + executable: string } ``` @@ -2909,11 +2949,16 @@ export interface Config { - `inject`: `sessionTitle` · `llm` · `sessions` - `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts) -- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:15`](../packages/experimental/session-title-all-prompts-llm/src/index.ts) +- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:28`](../packages/experimental/session-title-all-prompts-llm/src/index.ts) ```ts config-catalog -/** Required LLM policy; this plugin adds no defaults. */ -export type Config = SessionTitleLlmConfig +/** Required execution controls plus this provider's title-length targets; no defaults. */ +export interface Config extends SessionTitleLlmConfig { + /** Target word count for non-CJK titles. */ + readonly targetWords: number + /** Target character count for Chinese, Japanese, or Korean titles. */ + readonly targetCjkCharacters: number +} ``` @@ -2924,11 +2969,16 @@ export type Config = SessionTitleLlmConfig - `inject`: `sessionTitle` · `llm` · `sessions` - `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts) -- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:15`](../packages/session/session-title-first-prompt-llm/src/index.ts) +- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:28`](../packages/session/session-title-first-prompt-llm/src/index.ts) ```ts config-catalog -/** Required LLM policy; this plugin adds no defaults. */ -export type Config = SessionTitleLlmConfig +/** Required execution controls plus this provider's title-length targets; no defaults. */ +export interface Config extends SessionTitleLlmConfig { + /** Target word count for non-CJK titles. */ + readonly targetWords: number + /** Target character count for Chinese, Japanese, or Korean titles. */ + readonly targetCjkCharacters: number +} ``` @@ -3014,7 +3064,7 @@ export interface Config { ```ts config-catalog /** Office skill resource location. */ export interface Config { - /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ + /** Absolute assets directory containing skill folders and shared scripts; defaults to packaged assets. */ assetRoot?: string /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ node?: string @@ -3077,25 +3127,21 @@ export interface Config { ## `@deepseek-ai/dsh-ssh` -- `source`: [`packages/ssh/ssh/src/index.ts:17`](../packages/ssh/ssh/src/index.ts) +- `source`: [`packages/ssh/ssh/src/index.ts:33`](../packages/ssh/ssh/src/index.ts) ```ts config-catalog /** Deployment-owned SSH identity and installed helper; no model argument selects these values. */ export interface Config { /** OpenSSH host alias, including its existing user, key and known-host configuration. */ host: string - /** Absolute remote Node executable. */ - node: string + /** Explicit script or self-contained executable invocation. */ + launch: HelperLaunch /** Absolute path to the installed, bundled helper entry. */ helper: string /** SHA-256 of that bundled helper; mismatches refuse the connection. */ helperHash: string /** Absolute remote default workspace. */ workspace: string - /** Optional preinstalled built PTC entry, paired with its expected digest. */ - bootstrapPath?: string - /** SHA-256 of bootstrapPath; both fields must be supplied together. */ - bootstrapHash?: string /** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */ requestTimeoutMs?: number /** Maximum JSON payload bytes per helper request or response. */ @@ -3105,6 +3151,21 @@ export interface Config { /** Remote helper lease; loss of heartbeats starts remote managed cleanup. */ leaseMs?: number } + +/** Installed helper invocation; script deployments may also install a PTC bootstrap. */ +export type HelperLaunch = { + /** Run the installed script with a separately installed Node executable. */ + kind: 'node-script' + /** Absolute remote Node executable. */ + node: string + /** Absolute remote PTC bootstrap; requires bootstrapHash. */ + bootstrapPath?: string + /** Lowercase SHA-256 of bootstrapPath; requires that path. */ + bootstrapHash?: string +} | { + /** Run the helper executable with its embedded Node and PTC worker. */ + kind: 'executable' +} ``` @@ -3418,7 +3479,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:254`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:257`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -4207,7 +4268,7 @@ export interface WebRuntimeConfig { ## `@deepseek-ai/dsh-web-app` - `inject`: `webServer` -- `source`: [`packages/bundle/web-app/src/index.ts:46`](../packages/bundle/web-app/src/index.ts) +- `source`: [`packages/bundle/web-app/src/index.ts:55`](../packages/bundle/web-app/src/index.ts) ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ @@ -4217,22 +4278,18 @@ export interface Config { /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** - * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive - * layer can turn it off when its user is not in the GUI, so the - * orientation text would be false. + * Register the `DSH_WEB_URL` bash variable naming this GUI. A one-shot + * non-interactive layer can turn it off when its user is not in the GUI. */ surfaceContext: boolean /** * Canonical HTTP(S) root to advertise in the printed and opened URL, - * `DSH_WEB_URL`, and the web-surface orientation, e.g. - * `https://app.example/ui/`, normalized to end in `/`. Advertisement only; - * see [public deployments](../README.md#public-deployments). Absent or YAML - * `null` advertises the loopback URL. + * `DSH_WEB_URL`, and `webApp.getAdvertisedUrl()`, which supplies the Web GUI + * URL in agent runtime context, e.g. `https://app.example/ui/`, normalized to end in `/`. Configures no routing + * or authentication; see [public deployments](../README.md#public-deployments). + * Absent or YAML `null` advertises the bind-address URL. */ publicUrl?: string - /** Explicit `--trusted-host` authorities from this invocation. */ - trustedHosts: string[] } ``` @@ -4475,6 +4532,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-reference` | — | [`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts) | | `@deepseek-ai/dsh-client-ui-renderer` | — | [`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts) | | `@deepseek-ai/dsh-client-ui-schedule` | — | [`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-secret-mask` | — | [`packages/client/ui-secret-mask/src/index.ts`](../packages/client/ui-secret-mask/src/index.ts) | | `@deepseek-ai/dsh-client-ui-session` | — | [`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings` | — | [`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings-agent-loop` | — | [`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts) | @@ -4517,7 +4575,8 @@ export interface Config { | `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) | | `@deepseek-ai/dsh-fs-ssh` | `ssh` · `sandboxPolicy` | [`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts) | | `@deepseek-ai/dsh-goal-round-driver` | `agents` · `goals` · `sessions` | [`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts) | -| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) | +| `@deepseek-ai/dsh-harness-dev-context` | `systemPrompt` · `sessionProjections` · `webApp` | [`packages/context/harness-dev-context/src/index.ts`](../packages/context/harness-dev-context/src/index.ts) | +| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `connection` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) | | `@deepseek-ai/dsh-host-directory-picker-native` | — | [`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts) | | `@deepseek-ai/dsh-host-plugin-inventory` | `loader` | [`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts) | | `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) | @@ -4631,6 +4690,7 @@ export interface Config { | `@deepseek-ai/dsh-session-snapshot` | — | [`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts) | | `@deepseek-ai/dsh-session-telemetry` | — | [`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts) | | `@deepseek-ai/dsh-session-title-llm` | — | [`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts) | +| `@deepseek-ai/dsh-ssh-helper-runtime` | — | [`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts) | | `@deepseek-ai/dsh-timeout` | — | [`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts) | | `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) | | `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) | diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml index e37df25963..56f272ecf8 100644 --- a/docs/cookbook/adding-a-session-format-version.i18n.yaml +++ b/docs/cookbook/adding-a-session-format-version.i18n.yaml @@ -6,14 +6,14 @@ en: 71132d61e41cb40b zh: 0b56ed8893bf8d0a /cookbook-adding-a-session-log-format-version/summary: - en: 76b76c19e0889ab3 - zh: 1ddc1afa5c2333f0 + en: 3dc6121d1fcef4f8 + zh: 178bcf02b63c4fbf /cookbook-adding-a-session-log-format-version/table-of-contents: en: 6977fff29f49e4a4 zh: 7ffb6f5490831dce /cookbook-adding-a-session-log-format-version/1-choose-the-version-and-release-base: - en: 3205409b918b891d - zh: 98f17c19045954d5 + en: 3e763cfbe8cd11d5 + zh: 2d4998217cbe8030 /cookbook-adding-a-session-log-format-version/2-add-an-identity-edge: en: 77c9c26d94fb33ca zh: 313c54c45cfc7dbc diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index 7c6d08b993..8d960af752 100644 --- a/docs/cookbook/adding-a-session-format-version.md +++ b/docs/cookbook/adding-a-session-format-version.md @@ -4,7 +4,7 @@ English | [中文](adding-a-session-format-version.zh.md) ## Summary -Use this tutorial to introduce the next structural Session log version without rewriting released data. Read the [version and release-status authority](../session-format-status.md) to identify the checkout writer and the latest released format. Let N denote that verified released format and N+1 the target; substitute numeric values for these placeholders in names and metadata. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md). +Use this tutorial to introduce the next Session log format version without rewriting released data. Read the [version and release-status authority](../session-format-status.md) to identify the checkout writer and the latest released format. Let N denote that verified released format and N+1 the target; substitute numeric values for these placeholders in names and metadata. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md). ## Table of Contents @@ -20,15 +20,15 @@ Use this tutorial to introduce the next structural Session log version without r ## 1. Choose the version and release base -Bump the format for a structural change to headers, event envelopes, core event semantics, or surface reconstruction. Backward-compatible changes can remain at the current version through new acknowledgements; follow the [versioning rule](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) and [type-change rules](../persistence-changes/README.md#compatibility-rules). Distinguish the Session format integer from package release versions, SQLite schema versions, projection-unit versions, and protocol-wrapper versions. +Bump the format only when an effective discriminator cannot prevent unsafe interpretation. A structural change to headers, event envelopes, event semantics, or reconstruction requires assessment under the [reader-compatibility rule](../../.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md), not an automatic bump. Record safe same-version evolution through the [type-change procedure](reviewing-persistence-type-changes.md). Distinguish the Session format integer from package release versions, SQLite schema versions, projection-unit versions, and protocol-wrapper versions. -Let N be the accepted or published version in [version/status](../session-format-status.md) when planning the next breaking change. A breaking change after accepted V4 therefore needs V5 even before V4 publication is recorded; compatible additions do not require that step. +Let N be the accepted or published version in [version/status](../session-format-status.md) when a bump is required. After accepted V4, that bump needs V5 even before V4 publication is recorded; reviewed same-version evolution does not require that step. Use a shared `release/*` integration base for N+1. The base change adds the writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child’s branch. Each child adds its structural transformation, validators, consumers, and tests to the same adjacent migration package. Do not allocate extra versions just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it. -Released codecs and accepted target-format meanings remain stable. The N→N+1 converter may receive fixes or support additional historical cases after N+1 ships, without a version bump, if its output remains compatible with N+1. A converter fix affects subsequent conversions, not existing N+1 successors. Document changes to previously accepted mappings and how existing outputs remain supported. Changing the established target representation or interpretation incompatibly requires the next version. +Released codecs preserve accepted interpretations. A current-version codec can admit explicitly discriminated variants while retaining historical readability; do not broaden an older codec to admit a current feature. The N→N+1 converter may receive fixes or support additional historical cases after N+1 ships if its output remains compatible with N+1. A fix affects subsequent conversions, not existing N+1 successors. Document changed mappings and continued support for existing output; apply the reader-compatibility rule to any new target representation. -Use disposable, isolated Harness homes while N+1 remains open for integration. An interim N+1 file already has the target writer version, so a later edit to N→N+1 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home. +Same-version logs bypass adjacent migrations and can mix historical and newer record variants after resume. Each integer therefore identifies a family of supported representations, not one fixed schema. Owning readers must retain and distinguish those variants; the next outgoing migration must handle them. Do not add same-version migrations or rely on `restoreCurrent` alone for normalization: current-version JSONL reads using transformed validation bypass that hook. Use disposable, isolated Harness homes for interim integration formats; rerun changed adjacent conversions from unchanged historical input rather than rewriting a committed generation or using a real user's home. Before changing the writer, use the [archive command](../persistence-changes/historical-formats/README.md#maintenance) to preserve its complete persistence schema, then add the bilingual format reference under `docs/persistence-changes/historical-formats/vN.*`. Preserve all earlier records. The format coverage check requires every integer below the new writer to have its own document; the current generated catalog covers only the new writer. diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index bfadeab878..5cc5b3d197 100644 --- a/docs/cookbook/adding-a-session-format-version.zh.md +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -4,7 +4,7 @@ ## 概述 -本教程介绍如何添加下一个结构性 Session 日志版本,同时不改写已发布数据。阅读[版本与发布状态真源](../session-format-status.zh.md),确定工作区写入器与最新已发布格式。令 N 表示经核实的已发布格式,N+1 表示目标版本;名称与元数据中的这些占位符须替换为数字。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。 +本教程介绍如何添加下一个 Session 日志格式版本,同时不改写已发布数据。阅读[版本与发布状态真源](../session-format-status.zh.md),确定工作区写入器与最新已发布格式。令 N 表示经核实的已发布格式,N+1 表示目标版本;名称与元数据中的这些占位符须替换为数字。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。 ## 目录 @@ -20,15 +20,15 @@ ## 1. 选择版本与发布基线 -当 header、事件信封、核心事件语义或表面重建发生结构性变更时,提升格式版本。向后兼容的变更可以通过新的确认记录保留当前版本;遵循[版本规则](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)与[类型变更规则](../persistence-changes/README.zh.md#compatibility-rules)。区分 Session 格式整数与包发布版本、SQLite schema 版本、投影单元版本及协议包装层版本。 +只有有效判别信息无法阻止不安全解释时,才提升格式版本。头、事件封装、事件语义或重建的结构变更需要按[读取器兼容性规则](../../.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.zh.md)评估,不会自动升版本。通过[类型变更流程](reviewing-persistence-type-changes.zh.md)记录安全的同版本演进。区分 Session 格式整数与包发布版本、SQLite schema 版本、投影单元版本及协议包装层版本。 -规划下一次破坏性变更时,N 取[版本/状态](../session-format-status.zh.md)中的已接受或已发布版本。因此,即使尚未记录 V4 发布,已接受 V4 之后的破坏性变更也需要 V5;兼容新增不需要这一步。 +确定需要升版本时,N 取[版本/状态](../session-format-status.zh.md)中的已接受或已发布版本。已接受 V4 之后,即使尚未记录 V4 发布,此次升版本也需要 V5;经评审的同版本演进不需要这一步。 为 N+1 使用共享的 `release/*` 集成基线。基线变更添加写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个相邻迁移包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而分配额外版本。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。 -已发布 codec 和已接受的目标格式含义保持稳定。N+1 发布后,N→N+1 转换器仍可修复或增加历史场景支持;只要输出仍兼容 N+1,就不必提高版本。转换器修复只影响之后的转换,不会重新处理已有 N+1 后继文件。记录对原先已接纳映射的修改,以及已有输出如何继续得到支持。不兼容地改变既定目标表示或解释语义需要下一版本。 +已发布 codec 保留已接受的解释。当前版本 codec 可以接纳有明确判别信息的分支,同时保留历史可读性;不要为了接纳当前功能而放宽旧 codec。N+1 发布后,只要输出仍兼容 N+1,N→N+1 转换器就可以修复或增加历史场景支持。修复只影响之后的转换,不影响已有 N+1 后继文件。记录映射变化及已有输出如何继续得到支持;任何新的目标表示都须应用读取器兼容性规则。 -N+1 尚处于开放集成阶段时,应使用可丢弃、相互隔离的 Harness home。中间版本产生的 N+1 文件已标为目标写入器版本,因此后续对 N→N+1 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。 +同版本日志跳过相邻迁移,恢复后可以混合历史与较新的记录分支。因此,每个整数标识一组受支持表示,而非一个固定 schema。所属读取器必须保留并区分这些分支;下一条向外迁移必须处理它们。不要添加同版本迁移,也不要仅依赖 `restoreCurrent` 进行归一化:使用 transformed 校验的当前版本 JSONL 读取会跳过该 hook。中间集成格式使用可丢弃、相互隔离的 Harness home;相邻转换发生变化时,从未变更的历史输入重新运行,不得改写已提交代际或使用真实用户 home。 变更写入器之前,使用[归档命令](../persistence-changes/historical-formats/README.zh.md#maintenance)保留其完整持久化 schema,再在 `docs/persistence-changes/historical-formats/vN.*` 下添加双语格式参考。保留所有更早的记录。格式覆盖检查要求低于新写入器的每个整数版本都有独立文档;当前生成的目录只覆盖新写入器。 diff --git a/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml b/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml index efb3b93c60..eba1d60922 100644 --- a/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml +++ b/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml @@ -15,17 +15,17 @@ en: 5fca19ac2bb1ddac zh: 00d78fe7e80ba4f7 /cookbook-reviewing-persistence-type-changes/optional-inspect-the-change: - en: 3f85edb803afe870 - zh: 545c651b9e5ccb56 + en: 14049617e3de750d + zh: 200456cf72bef77b /cookbook-reviewing-persistence-type-changes/1-record-the-change: - en: 342bd49412db070e - zh: a8d14958e9a637d9 + en: de16b6e1ac883317 + zh: 21d367bb78e799d1 /cookbook-reviewing-persistence-type-changes/2-check-commit-and-push: en: 91d39719ec40b0f7 zh: 405116ae4c91b100 /cookbook-reviewing-persistence-type-changes/update-an-unaccepted-record: - en: 4fd3f48761f7909f - zh: 69af35c41656fa17 + en: e3872db4ec9aad7d + zh: 18de4f6ebcb4b31c /cookbook-reviewing-persistence-type-changes/dev-note: en: 5de1d6304e00c58f zh: 8d8225361cfa29e5 diff --git a/docs/cookbook/reviewing-persistence-type-changes.md b/docs/cookbook/reviewing-persistence-type-changes.md index 15b6c9fab9..56620e5a8b 100644 --- a/docs/cookbook/reviewing-persistence-type-changes.md +++ b/docs/cookbook/reviewing-persistence-type-changes.md @@ -31,7 +31,7 @@ pnpm --silent run verify-persistence-changes --json Use `--silent` when consuming JSON: pnpm otherwise appends lifecycle failure text to stdout. Failed commands still exit with code 1. -Read the reported root, path, change kind, and version requirement. A referenced type can affect several event digests; inspect every affected root. Until the history covers the new schemas, verification fails. A stale generated inventory also fails verification; the recording command refreshes it. If `changes` is empty after reordering fields or union alternatives, run `pnpm run gen-persistence-catalog` and repeat the check. An unchanged digest needs no new acknowledgement even when copied declarations or source locations produce a catalog diff. +Read the reported root, path, change kind, and compatibility-review requirement. A referenced type can affect several event digests; inspect every affected root. Until the history covers the new schemas, verification fails. A stale generated inventory also fails verification; the recording command refreshes it. If `changes` is empty after reordering fields or union alternatives, run `pnpm run gen-persistence-catalog` and repeat the check. An unchanged digest needs no new acknowledgement even when copied declarations or source locations produce a catalog diff. To review a PR independently of its acknowledgement history, save the base and head inventories as local JSON files and run: @@ -39,6 +39,8 @@ To review a PR independently of its acknowledgement history, save the base and h pnpm --silent run persistence-review --before .artifacts/base.schema.json --after docs/persistence-schema.json ``` +A recorded decision does not hide the transition from this comparison. `--check` only compares current source with acknowledged history; after recording, it cannot replace a base/head review or establish that a human reviewed the change. + Record the commits supplying those files with the report. Add `--json` for structured output. This read-only comparison groups shared changes with their affected roots and uses actual literal `kind`/`form` values instead of union positions. Ambiguous alternatives remain separate additions and removals. Its compatibility section copies every root's authoritative classifier result; the structural explanation does not replace acknowledgement checks. Current catalog labels and declaration names are descriptive metadata; structural anchors and fingerprints identify types. The current machine inventory stores complete graphs in `roots`. A `types` entry contains `digest`, `names`, and `sources`, plus an explicit `schema` when roots cannot reconstruct that graph exactly. Readers resolve omitted graphs from root subgraphs by digest and validate explicit graphs directly, preserving every type and its metadata. Historical full entries remain readable. `formatVersion` identifies normalization rules; storage compaction does not change root fingerprints or require an acknowledgement. @@ -46,7 +48,7 @@ The current machine inventory stores complete graphs in `roots`. A `types` entry ## 1. Record the change -Check the [accepted baseline](../session-format-status.md#finalization-record) first. Preserve its locked records. Record backward-compatible evolution in a new same-version acknowledgement; implement a higher writer version before recording a breaking change. +Check the [accepted baseline](../session-format-status.md#finalization-record) first and preserve its locked records. Assess both reader directions under the [compatibility rules](../persistence-changes/README.md#compatibility-rules). A structural difference can remain same-version with evidence of safe interpretation or refusal; it does not automatically require a higher writer version. Write a local JSON file containing `en` and `zh`, each with `summary`, `compatibility`, and `verification` strings. The following input describes an exercised required-to-optional hook audit field change. Replace the explanation and test evidence with facts about your change; the CLI does not establish these claims. @@ -73,9 +75,9 @@ Use a date and descriptive slug in place of this example id: pnpm --silent run persistence-changes --record 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json ``` -The command validates the history and paired prose, infers the minimum version decision, and checks any required header increase before writing. It generates the record pair, complete after schemas, both catalogs, the machine inventory, and pairing records. Review the explanations and returned `changes`, `roots`, and `files` before committing. Omitting `--prose` creates unfinished drafts that verification rejects until their explanations are completed. +The command validates the history and paired prose, infers established same-version decisions, and checks explicit compatibility decisions before writing. It generates the record pair, complete after schemas, both catalogs, the machine inventory, and pairing records. Review the explanations and returned `changes`, `roots`, and `files` before committing. Omitting `--prose` creates unfinished drafts that verification rejects until their explanations are completed. -Inference follows the [fixed compatibility rules](../persistence-changes/README.md#compatibility-rules); it never changes source or relaxes them. If a bump is required, first follow [adding a Session format version](adding-a-session-format-version.md). The record must include its own increasing `SessionHeader.version` transition; an unrelated historical bump cannot authorize it. Routine changes never create another baseline. +For a change marked `requiresCompatibilityReview`, explain both reader directions in the acknowledgement’s Compatibility section and record executed checks in Verification. Supply `--decision same-version` when that evidence supports keeping the version; recording or updating a flagged change with an unchanged header version and no explicit decision fails with `decision-required`. Structural differences alone do not infer a bump. An actual header version increase can infer `version-bump`. If an effective discriminator cannot prevent unsafe interpretation, follow [adding a Session format version](adding-a-session-format-version.md) and select `--decision version-bump`. That record must include its own increasing `SessionHeader.version` transition; an unrelated historical bump cannot authorize it. Routine changes never create another baseline. ## 2. Check, commit, and push @@ -99,14 +101,14 @@ If source changes after recording, review the compatibility explanation and refr pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json ``` -The command refreshes the machine declaration, schemas, catalogs, and pairing. Without `--prose`, it preserves the existing explanation. Update refuses the initial baseline, records that another record depends on, and finalized checkpoint records. Outside finalized checkpoints, the tree does not infer review acceptance: preserve accepted history and create a successor instead. +The command refreshes the machine declaration, schemas, catalogs, and pairing. Without `--prose`, it preserves the existing explanation. Reassess that explanation against the complete transition and supply `--decision same-version` for any flagged same-version update. Update refuses the initial baseline, records that another record depends on, and finalized checkpoint records. Outside finalized checkpoints, the tree does not infer review acceptance: preserve accepted history and create a successor instead. When integration creates competing terminal records, update the unaccepted record against the remaining history, then reassess the resulting diff. An unrelated root's acknowledgement does not need refreshing. The [mechanism decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) explains why complete snapshots and per-root predecessors are retained. -An explicit `--decision` remains a checked assertion. For an existing property's value-type change, the following deliberately wrong assertion fails before writing: +For an existing property value-type change requiring review, first revise the prose file’s Compatibility and Verification sections to explain the actual new transition and its executed checks. Do not reuse the earlier required-to-optional explanation for a different change. Then update the record with that prose and the explicit same-version choice: ```sh -pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --json +pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --prose .artifacts/persistence-change.prose.json --json ``` diff --git a/docs/cookbook/reviewing-persistence-type-changes.zh.md b/docs/cookbook/reviewing-persistence-type-changes.zh.md index ed82decae9..51d3f1b6c2 100644 --- a/docs/cookbook/reviewing-persistence-type-changes.zh.md +++ b/docs/cookbook/reviewing-persistence-type-changes.zh.md @@ -31,7 +31,7 @@ pnpm --silent run verify-persistence-changes --json 消费 JSON 时使用 `--silent`:否则 pnpm 会把生命周期失败文本追加到标准输出。失败命令仍以退出码 1 结束。 -阅读报告中的根、路径、变更种类和版本要求。被引用类型可能影响多个事件摘要;检查每个受影响的根。在历史覆盖新 schema 之前,验证会失败。陈旧生成清单也会导致验证失败;记录命令会刷新它。若重排字段或联合类型分支后 `changes` 为空,运行 `pnpm run gen-persistence-catalog` 并重新检查。即使复制的声明或源码位置产生目录 diff,未变的摘要也无需新增确认记录。 +阅读报告中的根、路径、变更种类和兼容性评审要求。被引用类型可能影响多个事件摘要;检查每个受影响的根。在历史覆盖新 schema 之前,验证会失败。陈旧生成清单也会导致验证失败;记录命令会刷新它。若重排字段或联合类型分支后 `changes` 为空,运行 `pnpm run gen-persistence-catalog` 并重新检查。即使复制的声明或源码位置产生目录 diff,未变的摘要也无需新增确认记录。 要独立于确认历史评审 PR,先将 base 和 head 的目录保存为本地 JSON 文件,再运行: @@ -39,6 +39,8 @@ pnpm --silent run verify-persistence-changes --json pnpm --silent run persistence-review --before .artifacts/base.schema.json --after docs/persistence-schema.json ``` +记录决策不会使转换从此比较中消失。`--check` 仅比较当前源码与已确认历史;记录完成后,它不能替代 base/head 评审,也不能证明人类已评审该变更。 + 在报告旁记录这些文件对应的 commit。添加 `--json` 可获取结构化输出。此只读比较将共享变更与受影响的根类型归组,使用实际字面量 `kind`/`form` 值代替联合类型位置。无法唯一匹配的候选项保留为独立的新增与删除。兼容性部分复制每个根类型的权威分类结果;结构说明不替代确认检查。当前目录标签和声明名称是描述元数据;结构锚点和指纹标识类型。 当前机器清单在 `roots` 中保存完整图。每个 `types` 条目包含 `digest`、`names` 和 `sources`;若根无法精确重建该图,条目还会保存显式 `schema`。读取器按摘要从根的子图恢复省略的图,并直接验证显式图,保留每个类型及其元数据。历史完整条目仍然可读。`formatVersion` 标识规范化规则;存储压缩不改变根指纹,也不需要确认记录。 @@ -46,7 +48,7 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte ## 1. 记录变更 -先检查[已接受基线](../session-format-status.zh.md#finalization-record),保留其锁定记录。向后兼容的演进使用新的同版本确认记录;记录破坏性变更之前,先实现更高的写入器版本。 +先检查[已接受基线](../session-format-status.zh.md#finalization-record),保留其锁定记录。按[兼容性规则](../persistence-changes/README.zh.md#compatibility-rules)评估两个读取方向。若有安全解释或拒绝的证据,结构差异可以保留同一版本,不会自动要求更高的写入器版本。 编写包含 `en` 和 `zh` 的本地 JSON 文件,两者分别包含 `summary`、`compatibility` 和 `verification` 字符串。以下输入描述一个经过验证的钩子审计字段从必选改为可选的变更。用你所做变更的事实替换说明和测试证据;CLI(命令行界面)不会证明这些声明。 @@ -73,9 +75,9 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte pnpm --silent run persistence-changes --record 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json ``` -命令在写入前验证历史和双语说明、推断最低版本决策,并检查所需的头部版本递增。它生成记录对、完整的变更后 schema、两份目录、机器清单和配对记录。提交前审阅说明及返回的 `changes`、`roots` 和 `files`。省略 `--prose` 会创建未完成草稿,验证将拒绝它们,直到说明补齐。 +命令在写入前验证历史与双语说明、推断已明确允许的同版本决策,并检查显式兼容性决策。它生成记录对、完整的 after schema、两个目录、机器清单与配对记录。提交前检查说明及返回的 `changes`、`roots`、`files`。省略 `--prose` 会生成未完成草稿;说明填写完成前,验证会拒绝它们。 -推断遵循[固定兼容性规则](../persistence-changes/README.zh.md#compatibility-rules),不会更改源码或放宽规则。需要升版本时,先遵循[添加会话格式版本](adding-a-session-format-version.zh.md)。记录必须包含其自身的 `SessionHeader.version` 递增转换;无关的历史升版本不能授权它。日常变更不创建另一条基线。 +对于标为 `requiresCompatibilityReview` 的变更,在确认记录的兼容性部分说明两个方向的读取器行为,并在验证部分记录已执行的检查。证据支持保持版本时,提供 `--decision same-version`;记录或更新被标记的变更时,若头版本未变且缺少显式决策,会以 `decision-required` 失败。仅凭结构差异不会推断升版本。实际头版本递增可以推断出 `version-bump`。若有效判别信息无法阻止不安全解释,遵循[添加会话格式版本](adding-a-session-format-version.zh.md),并选择 `--decision version-bump`。该记录必须包含自身的 `SessionHeader.version` 递增转换;无关的历史升版本不能授权它。日常变更不创建另一条基线。 ## 2. 检查、提交并推送 @@ -99,14 +101,14 @@ pnpm run doc-sync pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json ``` -命令刷新机器声明、schema、目录和配对。没有 `--prose` 时,它保留已有说明。更新会拒绝初始基线、其他记录所依赖的记录,以及已被定稿检查点锁定的记录。定稿检查点之外,目录不会推断审阅接受状态:保留已接受历史,并创建后继。 +命令刷新机器声明、schema、目录和配对。没有 `--prose` 时,它保留已有说明。根据完整转换重新评估说明,并为任何被标记的同版本更新提供 `--decision same-version`。更新会拒绝初始基线、其他记录所依赖的记录,以及已被定稿检查点锁定的记录。定稿检查点之外,目录不会推断审阅接受状态:保留已接受历史,并创建后继。 集成产生竞争末端记录时,根据剩余历史更新尚未接受的记录,再重新评估最终差异。无关根的确认无需刷新。[机制决策](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)解释为何保留完整快照和逐根前驱。 -显式 `--decision` 仍是受检查的断言。若已有属性的值类型发生变化,下面这个故意错误的断言会在写入前失败: +若已有属性的值类型变化需要评审,先修改说明文件中的兼容性与验证部分,解释实际的新转换及已执行检查。不要为不同变更复用先前必选改可选的说明。然后使用该说明与显式同版本选择更新记录: ```sh -pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --json +pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --prose .artifacts/persistence-change.prose.json --json ``` diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml index ca1ec47310..79b48d6dc5 100644 --- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml +++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml @@ -12,8 +12,8 @@ en: 740e92e903ecb594 zh: f934d3c9e5c56bfe /official-deepseek-llm-api-wire-extensions/body-extension-transaction: - en: f0b1443c364a431c - zh: 16d16ddf5c451e8e + en: 48001cfd1281a23b + zh: d410530ae8aa2823 /official-deepseek-llm-api-wire-extensions/dsh-plugin-packages: en: f89059050613f3e8 zh: da5cf2a4eeb81b75 diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md index 216725d71d..1a30f7c463 100644 --- a/docs/deepseek-llm-api-wire-extensions.md +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -34,9 +34,9 @@ Credential failure happens before anonymous-user-id resolution, so an unauthoriz The adapter serializes the complete base body, including the exact `messages`, before it asks registered providers to prepare fields. A provider receives that immutable body, the request cancellation signal, and optional `sessionId` and auxiliary-call `purpose`. Returning `undefined` omits that provider's field for the request. -Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. Preparation or collision failure prevents the request. If the merged body fails to serialize, the adapter sends the base body without any extension field, skips the acceptance transaction so contributors resend their state on a later request, and logs the omitted field names. A composition without the registry sends the unextended base body. +Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. A contributor whose preparation fails is omitted from that request, with only its first failure logged, while the other fields are still sent; a field that collides with a base field prevents the request. If the merged body fails to serialize, the adapter sends the base body without any extension field, skips the acceptance transaction so contributors resend their state on a later request, and logs the omitted field names. A composition without the registry sends the unextended base body. -After the configured endpoint returns HTTP 2xx, the adapter runs the prepared `accept()` transaction before reading the SSE response body. Transport failures and non-2xx responses do not accept any contribution. An acceptance failure fails the model request even though the endpoint returned 2xx. Acceptance records endpoint-level HTTP success; it does not assert that an SSE stream completed or that the endpoint persisted an extension. +After the configured endpoint returns HTTP 2xx, the adapter runs the prepared `accept()` transaction before reading the SSE response body. Transport failures and non-2xx responses do not accept any contribution. An acceptance failure is logged and does not fail the model request; the contributor resends its unaccepted state on a later request. Acceptance records endpoint-level HTTP success; it does not assert that an SSE stream completed or that the endpoint persisted an extension. ## `dsh_plugin_packages` diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md index 33c8908ae5..dcccecc6a7 100644 --- a/docs/deepseek-llm-api-wire-extensions.zh.md +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -34,9 +34,9 @@ 适配器先序列化包括确切 `messages` 在内的完整基础正文,再让已注册提供方准备字段。提供方会收到该不可变正文、请求取消信号,以及可选的 `sessionId` 和辅助调用 `purpose`。提供方返回 `undefined` 时,本次请求会省略其字段。 -系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。准备失败或冲突会阻止请求。合并后的正文无法序列化时,适配器发送不含任何扩展字段的基础正文,跳过接受事务,让贡献方在之后的请求中重发自身状态,并记录被省略的字段名。组合未挂载注册表时,适配器发送未经扩展的基础正文。 +系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。某个贡献方准备失败时,系统在该次请求中省略其字段,且只记录首次失败,其余字段照常发送;与基础字段冲突的字段会阻止请求。合并后的正文无法序列化时,适配器发送不含任何扩展字段的基础正文,跳过接受事务,让贡献方在之后的请求中重发自身状态,并记录被省略的字段名。组合未挂载注册表时,适配器发送未经扩展的基础正文。 -已配置端点返回 HTTP 2xx 后,适配器会在读取 SSE 正文之前运行已准备的 `accept()` 事务。传输失败和非 2xx 响应不会接受任何贡献。即使端点返回 2xx,接受失败仍会使模型请求失败。接受仅记录端点级 HTTP 成功,不表示 SSE 流已完整结束,也不表示端点已持久化扩展。 +已配置端点返回 HTTP 2xx 后,适配器会在读取 SSE 正文之前运行已准备的 `accept()` 事务。传输失败和非 2xx 响应不会接受任何贡献。接受失败会记录告警,但不会使模型请求失败;贡献方会在之后的请求中重发未被接受的状态。接受仅记录端点级 HTTP 成功,不表示 SSE 流已完整结束,也不表示端点已持久化扩展。 ## `dsh_plugin_packages` diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml deleted file mode 100644 index d9a68ffae5..0000000000 --- a/docs/defensive-patterns.i18n.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# Bilingual-pair consistency record for defensive-patterns.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/defensive-patterns.md -/defensive-patterns: - en: 6316b1da0ae4561b - zh: f059647429c707d5 -/defensive-patterns/report-orthogonal-outcomes-independently: - en: 09cdbaa7cf763dc9 - zh: 11e5c13d77b2a563 -/defensive-patterns/honor-public-contracts-on-both-sides: - en: 157f966db7329171 - zh: 80da311819125f36 -/defensive-patterns/async-state-is-not-synchronous-state: - en: 33085f2b58bab16c - zh: be38e69a9c7d3f0e -/defensive-patterns/dispose-must-reach-quiescence-not-just-request-it: - en: 5c906be504b88a65 - zh: 8f428f59a87bb1a8 -/defensive-patterns/contain-callback-exceptions-in-the-dispatcher: - en: ae1788e76190db2c - zh: a55ab7473bb0c8dd -/defensive-patterns/never-hand-untrusted-output-the-ambient-environment-or-predictable-paths: - en: 09ad9d6433026468 - zh: cc0ba1a52859bb08 -/defensive-patterns/unlink-link-shaped-paths: - en: 644e6b1a9bd7b7d7 - zh: 823600c76c81ead1 diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md deleted file mode 100644 index 9db5823546..0000000000 --- a/docs/defensive-patterns.md +++ /dev/null @@ -1,33 +0,0 @@ -# Defensive patterns - -English | [中文](defensive-patterns.zh.md) - -Hard-won bug-class rules: each pattern below is a class of defect that actually shipped or nearly shipped here, stated as the rule that prevents its recurrence. Read this before writing lifecycle, concurrency, subprocess, or teardown code. Test-tier counterparts (real entry path, world-verification, resource ownership) are in [testing.md](testing.md). - -## Report orthogonal outcomes independently - -A result can be several things at once — a process can time out AND exit 0 because it trapped the signal. Surface each independent fact (`timedOut`, `signal`, `exitCode`) on its own; never nest one flag's report inside another's branch, or a caller reads a cut-short run as a clean success. - -## Honor public contracts on BOTH sides - -When an implementation receives several representations of one outcome, normalize them before returning through the public API. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmRuntime.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. - -## Async state is not synchronous state - -`agent.followup()` has no per-message completion or result; a background job's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never treat `agent/status` or `whenIdle()` as the result of one follow-up: several queued follow-ups, steering, and injected work may share one `running` interval, while cancellation or disposal can discard unstarted items. An automation caller that truly owns a run must define its interval explicitly—for example, from its message's durable inbox receipt through the next whole-agent `idle`—and describe any selected output as interval-wide rather than causally attributed to that message. The guard cuts both ways: if the awaited transition can never occur, the wait hangs, so handle the "nothing to wait for" branch explicitly. - -## Dispose must reach quiescence, not just request it - -A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. - -## Contain callback exceptions in the dispatcher - -A user-supplied listener that throws must not reject the promise it runs inside or starve the listeners after it. Wrap the dispatch loop in try/catch and log; one bad subscriber never breaks core lifecycle. - -## Never hand untrusted output the ambient environment or predictable paths - -Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. - -## Unlink link-shaped paths - -A path that may be a symlink or Windows junction is removed with `lstatSync().isSymbolicLink()` then `unlinkSync`: unlink deletes only the link and refuses a real directory, so it never follows the link into its target. Windows `rmSync(link)` throws `ERR_FS_EISDIR` on a junction; recursive deletion may descend through one into its target. Reserve recursive `rmSync` for known real directories. diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md deleted file mode 100644 index 11b2a34a40..0000000000 --- a/docs/defensive-patterns.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# 防御性模式 - -[English](defensive-patterns.md) | 中文 - -来之不易的缺陷类别规则:下面每条模式都是本项目实际发布或差点发布的一类缺陷,以防止其复发的规则形式陈述。在编写生命周期、并发、子进程或清理代码之前请先阅读本文。测试层面的对应规则(真实入口路径、验证实际结果、资源归属)见 [testing.md](testing.zh.md)。 - -## 正交结果独立上报 - -一个结果可以同时具有多种性质:进程可能已经超时,却仍以退出码 0 结束,因为它捕获了终止信号。每个独立事实(`timedOut`、`signal`、`exitCode`)都应单独上报;切勿把一个标志的上报嵌套在另一个标志的分支中,否则调用方可能把提前终止的运行误判为正常成功。 - -## 公共约定两侧都要遵守 - -当一个实现收到同一结果的多种表示时,应在通过公共 API 返回前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmRuntime.stream()` 只会通过终止型 finish 分片暴露模型请求失败;middleware 缺陷与消费方缺陷仍会以异常形式抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、分片日志记录还是自身组装逻辑。请在类型定义处记录规范化后的约定;通过真实消费方覆盖每种来源形式。 - -## 异步状态不是同步状态 - -`agent.followup()` 没有逐消息的完成状态或结果;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿把 `agent/status` 或 `whenIdle()` 当作某次 `followup()` 的结果:多条已排队的后续消息、steering(中途引导)和注入工作可能共用同一个 `running` 区间,而取消或资源释放可能丢弃尚未启动的项。真正拥有一次运行的自动化调用方必须显式定义其区间——例如从消息的持久 inbox 回执到整个 agent(智能体)下一次进入 `idle`——并将选取的任何输出描述为整个区间的输出,而不是把因果关系归于该消息。这条守则是双向的:如果等待的转换永远不会发生,等待就会挂起,因此应显式处理「无需等待」的分支。 - - - -## dispose 必须达到完全停稳,而不仅仅是请求停止 - -如果清理流程只发出终止或中止信号便返回,而不等待工作真正停止,就会留下孤儿进程。清理逻辑应采用异步流程,并等待子进程退出(发出终止信号后等待 `done`);还应在终止进程前关闭监听器注册表和通知注册表,使迟到的完成事件保持静默。 - -## 在分发器中隔离回调异常 - -用户提供的监听器如果抛出异常,不得导致它所在的 promise 被 reject,也不得饿死排在它后面的监听器。请用 try/catch 包裹分发循环并记录日志;一个行为不当的订阅者绝不能破坏核心生命周期。 - -## 绝不将环境变量或可预测路径暴露给不可信输出 - -启动的命令应使用经过清理的环境变量,移除名称匹配 `*KEY*`、`*SECRET*`、`*TOKEN*` 或 `*PASSWORD*` 的项,防止 harness 凭证通过命令输出、`env` 或 spill 文件泄漏。临时文件和 spill 文件应放在权限为 0700 的私有目录中,使用随机文件名,并以独占且仅所有者可访问的方式打开(`'wx'`、`0o600`);可预测且全局可读的路径会引发符号链接竞态和信息泄露。 - -## 用 unlink 删除链接形态的路径 - -可能是符号链接或 Windows junction 的路径,应先用 `lstatSync().isSymbolicLink()` 判断,再用 `unlinkSync` 删除:unlink 只删除链接本身并拒绝真实目录,因此绝不会跟随链接进入其目标。Windows 上对 junction 调用 `rmSync(link)` 会抛 `ERR_FS_EISDIR`;递归删除可能穿过 junction 进入其目标。真实目录才使用带 `recursive` 的 `rmSync`。 diff --git a/docs/defensive-patterns/README.i18n.yaml b/docs/defensive-patterns/README.i18n.yaml new file mode 100644 index 0000000000..79e3b6cd15 --- /dev/null +++ b/docs/defensive-patterns/README.i18n.yaml @@ -0,0 +1,10 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/defensive-patterns/README.md +/defensive-patterns: + en: 7229c49e920aa5b3 + zh: 1dd52db6b4d84952 +/defensive-patterns/why-these-rules: + en: 7c012071b5af8c98 + zh: 298aaba83625c7a0 diff --git a/docs/defensive-patterns/README.md b/docs/defensive-patterns/README.md new file mode 100644 index 0000000000..c74d1a4c29 --- /dev/null +++ b/docs/defensive-patterns/README.md @@ -0,0 +1,25 @@ +# Defensive patterns + +English | [中文](README.zh.md) + +Hard-won rules: each pattern in this directory is a requirement this repository enforces in review, stated as the rule and the defect it prevents. Every rule states what must hold, the condition a reviewer judges it by, its boundary and who approves an exception, and a correct and a wrong example. Read the page for the artifact you are about to change, before you change it. + +## Why these rules + +Five habits cover most of what follows, and each page is their application to one artifact: + +- **Make correctness explicit.** Write it into the structure — named call points, explicit lifecycles, explicit conditions and types — rather than compensating for it afterwards with timing. +- **Keep one source of truth.** One read path for a piece of data, one home for a fact, one owner for shared state. +- **Extend through the registered seam.** A new capability registers; an existing carrier stops taking new logic instead of growing a branch inside it. +- **Keep the change face equal to the review face.** A change carries only the lines its stated behavior change needs, a migration moves code rather than rewriting it, and a performance-sensitive change carries its measurement. +- **Require evidence.** A claim is falsifiable and recomputable — measured before and after, or checked by a gate — rather than kept in sync by hand. + +| Changing | Read | +|---|---| +| Package exports, package shape, build faces, dependency declarations | [package-surface.md](package-surface.md) | +| Remote services, `@Remote` methods, API dispatch, business ownership at the API layer | [remote-and-api.md](remote-and-api.md) | +| Client components, slots, stores, hooks, effects | [client-components.md](client-components.md) | +| Session reads, retention, conversation nodes, renderers | [conversation-and-session.md](conversation-and-session.md) | +| Timing, state representation, shared ownership, decentralization, refactors, migrations, performance evidence, disposal, callbacks, subprocesses | [general.md](general.md) | + +Type definitions and per-subsystem semantics live in [subsystems/](../subsystems/README.md); this directory carries the rules, and the semantics pages carry what the types mean. [dsh-code-review](../../.agents/skills/dsh-code-review/SKILL.md) reads this directory when judging a pull request, and Development routes through the same table from [AGENTS.md](../../AGENTS.md). diff --git a/docs/defensive-patterns/README.zh.md b/docs/defensive-patterns/README.zh.md new file mode 100644 index 0000000000..38d4c617d2 --- /dev/null +++ b/docs/defensive-patterns/README.zh.md @@ -0,0 +1,27 @@ +# 防御性模式 + +[English](README.md) | 中文 + +来之不易的规则:本目录中每条模式都是本仓库在评审中执行的**要求**,以「规则 + 它所防止的缺陷」的形式陈述。每条规则都写明:必须成立什么、评审据什么判定、边界在哪、例外由谁批准,以及一个正例与一个反例。动手修改某类制品之前,先读对应那一页。 + + + +## 这些规则背后的思想 + +下面五条习惯覆盖了绝大部分内容,每一页都是它们在某类制品上的落地: + +- **把正确性显式化。** 把它写进结构——具名调用点、显式生命周期、显式条件与类型——而不是事后用时序去补偿。 +- **只留一个真相。** 一份数据一条读取路径,一个事实一个正家,共享状态一个所有者。 +- **走注册面扩展。** 新能力靠注册接入;既有载体不再承接新逻辑,而不是在它内部长出分支。 +- **把改动面压成评审面。** 改动只包含其声明的行为变化所需的行;迁移是搬代码而不是重写;涉及性能的改动附带测量。 +- **要求可证伪、可复算。** 口径能被改动前后的实测或门禁证明/否证,而不是靠人肉同步。 + +| 你在改什么 | 读哪篇 | +|---|---| +| 包导出面、包形态、构建面、依赖声明 | [package-surface.zh.md](package-surface.zh.md) | +| 远程服务、`@Remote` 方法、API 分发、API 层的业务归属 | [remote-and-api.zh.md](remote-and-api.zh.md) | +| 客户端组件、slots、store、hooks、effect | [client-components.zh.md](client-components.zh.md) | +| Session 读取、持有、会话节点、renderer | [conversation-and-session.zh.md](conversation-and-session.zh.md) | +| 时序、状态表示、共享归属、去中心化、重构、迁移、性能证据、清理、回调、子进程 | [general.zh.md](general.zh.md) | + +类型定义与各子系统语义见 [subsystems/](../subsystems/README.zh.md);本目录承载**规则**,语义页承载**类型的含义**。[dsh-code-review](../../.agents/skills/dsh-code-review/SKILL.md) 在评审 pull request 时读本目录,日常修改经 [AGENTS.md](../../AGENTS.md) 路由到同一张表。 diff --git a/docs/defensive-patterns/client-components.i18n.yaml b/docs/defensive-patterns/client-components.i18n.yaml new file mode 100644 index 0000000000..face24c503 --- /dev/null +++ b/docs/defensive-patterns/client-components.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for client-components.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/defensive-patterns/client-components.md +/client-component-rules: + en: a6e8a87976040c04 + zh: 08b59ad3c14fe250 +/client-component-rules/state-arrives-through-injection: + en: 112232a703ce472d + zh: cf42bda22d8e4da4 +/client-component-rules/props-carry-data-and-callbacks-not-react-nodes: + en: 253f05568dd7e26f + zh: fa12565ff5afb8bd +/client-component-rules/a-slot-registration-is-a-stable-function-component: + en: 784d3a4bba02f8a4 + zh: 0ccfcc1a504b135c +/client-component-rules/components-never-receive-a-cordis-context: + en: 8bb102b178615c03 + zh: 93cb177a578a1373 +/client-component-rules/context-comes-from-the-current-scope: + en: 652ec94d13ee51c9 + zh: b47c39e96c53cfac +/client-component-rules/resource-consumers-go-through-useresource: + en: 6650c772df96f935 + zh: 57fc0a564caf62a8 +/client-component-rules/store-usage-is-narrow-and-explicit: + en: 697b2e81a862e3bf + zh: 13cf31bef332a601 +/client-component-rules/identity-and-initial-state-are-deliberate: + en: a34c30b8f7c415d4 + zh: cbd0c0cf0c0db57c +/client-component-rules/uselayouteffect-is-not-a-default: + en: f53f17846a8a89b2 + zh: 94d205c4d85f1de9 +/client-component-rules/effects-never-push-render-derived-data-to-a-parent: + en: 580441bd6cce9ee9 + zh: a5f093d8e7dec2ee +/client-component-rules/build-and-style-follow-their-owners: + en: 561df1e1709a004c + zh: 89ca6eb4533648b8 +/client-component-rules/a-key-states-what-it-identifies: + en: 1d6f9bdc2b267fef + zh: cf4d579ca877c422 +/client-component-rules/each-shared-layer-keeps-its-own-isolation-level: + en: 3faec81a6a7c9fdd + zh: aa512c089b5f9afc diff --git a/docs/defensive-patterns/client-components.md b/docs/defensive-patterns/client-components.md new file mode 100644 index 0000000000..7b0ec0463a --- /dev/null +++ b/docs/defensive-patterns/client-components.md @@ -0,0 +1,493 @@ +# Client component rules + +English | [中文](client-components.zh.md) + +Rules for client components, their state, and their effects. [packages/client/AGENTS.md](../../packages/client/AGENTS.md) carries the detailed discipline; [slots.md](../subsystems/slots.md) owns the Slot design and [web-styling.md](../web-styling.md) the styling rules. Each section below states the requirement, how a reviewer judges it, where its boundary and exception sit, and a correct and a wrong example. + +## State arrives through injection + +A Slot's component receives everything it renders through the inject face of its own registration — plain data, action callbacks, and the reserved `hooks` compartment the renderer binds to `use` hooks — while the parent's render site passes owner data through the derived `renderSlot`. It never reads one large global state at the top of the tree to hand values down. Keeping every live value on that face is what makes a prop that did not change keep its identity, and what lets a component subscribe only to the sub-state it reads. Without it any unrelated field change re-renders the whole subtree, which is how a long session under streaming events loses frames while the user scrolls or types. + +- Judged by: at the registration and at its render site, the author must be able to name which sub-states the rendered component subscribes to and which of its incoming props keep a stable identity across renders. Watch the mutability of every prop: a value rebuilt each render — a fresh object, array, or inline callback — propagates re-renders even when nothing in it changed. +- Not covered: the component's own local state (`useState` for a fold, a hover, a draft) is not injection content, and a component that subscribes to nothing outside React needs no new seat. +- Exception: a new hook seat changes the Slot contract, so that change states its impact — which components subscribe to what — in review; using an existing face needs no per-component approval. +- Fix: narrow the injection to what the view renders, expose one field per read through a hook seat, and pass a narrow action set instead of a store or service handle. [Component inputs](../subsystems/slots.md#component-inputs) owns the inject factory, the standard hook seats, and how each becomes a prop. + +The registration carries plain data, a narrow action set, and one hook seat; the render site passes owner data, and the component reads only the field it renders: + +```tsx +// The inject factory returns plain data and callbacks; hooks is the reserved compartment. +ctx.slots.register( + { + name: 'sidebar.right', + inject: () => ({ + rename, + hooks: { info: infoSource }, // bound by the renderer as useInfo() + }), + }, + SidebarRight, +) + +// The parent's render site passes owner data through the derived renderSlot. +{renderSlot('sidebar.right', { id })} + +function SidebarRight({ id, useInfo, rename }: Props) { + const { title } = useInfo() // subscribes to title only + return +} +``` + +The top-level component reads the whole view store and hands it down as one prop, so the render site can no longer answer either question above: + +```tsx +function Sidebar() { + const view = useViewStore() // one top-level global read + return // the whole store travels down +} + +function SidebarRight({ view }: { view: ViewStore }) { + return {view.activePanel.title} +} +``` + +Every field change in that store re-renders the whole sidebar subtree, and the component's subscription set is invisible to the site that bound it. + +## Props carry data and callbacks, not React nodes + +A prop is data or a callback; a React node travels through a declared slot. Passing an element as a prop value or an injected member hides a second composition path that the Slot contract does not describe, moves render control from the registration surface to the call site, and lets one package assemble another package's markup. New node-valued props or injected members are never added; a location that needs host-supplied markup is declared as a child slot instead. + +- Judged by: `ReactNode`, `ReactElement`, or a `() => JSX.Element` in a component's props type, in an inject factory's return value, or in an owner-props object at a render site. +- Not covered: node-valued fields that already shipped keep working. The rule triggers when a change touches one, and that change converts it rather than extending it. +- Exception: when the conversion cannot happen in the same change, the owning domain expert confirms the deferral and the change records that confirmation; the author never self-approves. +- Fix: declare the location in the parent registration's `children`, render it with `renderSlot`, and pass data through the child's props. [Extension rules](../subsystems/slots.md#extension-rules) own which package may declare and render which slot. + +The header location is a declared child slot, so the host passes data and the registered component owns its markup: + +```tsx +// The card declares the header location; the host passes data only. +ctx.slots.register( + { + name: 'tool.call.toolview', + children: { header: { kind: 'single', scope: 'session' } }, + }, + ToolCard, +) + +function ToolCard({ title, renderSlot }: Props) { + return
{renderSlot('header', { title })}
+} +``` + +A React node crosses the boundary as a prop value, so the composition path it travels is not declared anywhere: + +```tsx +type ToolCardProps = { + header?: React.ReactNode + footer?: React.ReactNode +} + +// The call site assembles another package's markup. +} /> +``` + +The card's Slot contract stops describing what it renders, the call site keeps render control, and the badge's package becomes a runtime reference from whoever passes it. + +## A slot registration is a stable function component + +The value registered into a slot is a stable function component declared at module scope. A component function created during render is a new component type on every pass, and React treats a changed type as a different component: it unmounts the subtree and mounts a fresh one, which resets expanded rows, input focus, and scroll position. Registration-time parameters reach the component through props rather than by closing over render-time variables. + +- Judged by: where the registered value is created. An inline arrow in a registration or render call, a component defined inside a render body, and a `useCallback` wrapper around an inline component all fail; a module-scope `function` declaration passes. +- Not covered: the rule constrains the identity of the registered value, not its complexity. A stable component may still read injected props, hooks, and callbacks. +- Exception: creating a component during render is correct only when the caller wants the remount, and an ordinary change needs no separate approval; a change to the form of the Slot contract follows the injection-face review described under State arrives through injection. +- Fix: move the component to module scope, type its props from the registration's shares, and pass the varying value through the inject face instead of the closure. + +The registration holds one stable reference to a module-scope component, and the varying value arrives injected; the render site supplies the local component: + +```tsx +function PanelTitle({ title, count }: Props) { + return {title} ({count}) +} + +// Registration: the local slot's scope is declared, never a component. +ctx.slots.registerFactory( + { + name: 'panel', + scope: 'root', + slots: { title: { scope: 'root' } }, + inject: () => ({ title: 'Sessions', count: 3 }), + }, + Panel, +) + +// The render site passes the local component, keeping its identity stable. +renderFactorySlot('panel', { /* owner data */ }, { slots: { title: PanelTitle } }) +``` + +An inline arrow at the render site creates a new component type whenever that render runs: + +```tsx +// A new component type each time this render runs. +renderFactorySlot('panel', { /* owner data */ }, { slots: { title: (p) => {p.title} } }) +``` + +React sees a different type than the previous registration, remounts the seat, and resets its local state — expanded, focused, or scrolled position — on an otherwise unrelated update. + +## Components never receive a Cordis context + +`ctx` belongs to the apply world: the plugin body and the `inject` factories closed over it. A component never receives a Cordis context, never imports a service class to poke it, and never reads a React context, because business components see zero contexts — the renderer's binding contexts are internal to it. A bare observable reaches a component only after Injection wraps it in a hook; a component that subscribes directly owns a lifecycle that nothing on the Injection face releases. + +- Judged by: `ctx` or `Context` in a component's parameter or props type; a component body reading `observable.value`, calling `subscribe`, or wiring `useSyncExternalStore`; a service class imported into a feature `.tsx` module. +- Not covered: a component-internal behavioral hook that subscribes to nothing outside React (a timer, a media query) is not injection content and stays in the component. +- Exception: none. The direct subscription still present in `TabSlot` is remediation work owned by that entry's owner, not a precedent for new code. +- Fix: move the fact into the registration's `inject` — plain data and callbacks, with bare observables in the reserved `hooks` compartment — or into a declared store, and read it through the derived seat. + +Injection binds the observable, and the component reads one derived hook whose subscription the renderer owns: + +```tsx +// The inject factory is the only place that closes over ctx. +const inject = () => ({ + hooks: { tab: tabStore.observable }, + action: { openTab }, +}) + +function TabView({ useTab, action }: Props) { + const { loaded } = useTab() // the renderer owns the subscription + return +} +``` + +The whole view store is injected for the component to pick fields from, and a module-level handle takes instance lifecycle away from the framework: + +```tsx +// The component receives the store itself and picks its own fields. +const inject = () => ({ viewStore }) + +function SidebarHeader({ viewStore }: Props) { + return +} + +// Module-level handle: one identity shared by every plugin load. +export const paneStore = createPaneStore() +``` + +The component's dependency face stops being auditable, and the shared module identity outlives every scope teardown: two panes end up on one instance instead of one each. + +## Identity and initial state are deliberate + +Switching state does not remount a control or replace its DOM node: the instance and its DOM identity stay the same across a state change, so focus, selection, and an in-progress draft survive it. Initial state is ready when the first flow runs — the same path prepares the data and establishes the state — instead of being filled in later by an effect. A component receives hooks, selectors, and action callbacks rather than a whole service. + +- Judged by: after a state change, whether the control instance and its DOM node keep their identity; an unmount followed by a mount is the violation. The initialization path is judged the same way: state established inside the first flow passes, state written from an effect fails. +- Dependency face: hooks read, selectors pick a sub-state, and action callbacks write back. A whole service passed as a prop or an injected member fails. +- Scope: input and command controls that hold an internal instance and local state, and business views created by a factory. +- Exception: a genuinely required remount — switching to a data set whose instance state cannot migrate — is explained in review together with the state migration plan; the reviewer of a change that affects a control's instance lifetime accepts it. +- Fix: derive the initial value where the first flow builds its state, keep the layout node mounted and vary its props, and replace a service prop with the three narrow shares. + +The draft exists with the session and the composer node stays mounted while only its props change: + +```tsx +// The draft is built by the first flow; nothing fills it in later. +const draft = useDraftState(sessionId) +const { canSend } = useComposerSelectors(draft) + +// The node stays mounted; state changes reach it as props. +return ( +
+ {draft.text} +
+) +``` + +Initial state arrives one commit late, and the keyed control is rebuilt whenever the session changes: + +```tsx +// The draft is filled in after the first commit. +useEffect(() => { setDraftState(initialDraft) }, []) + +// The keyed composer unmounts on every session switch. + +``` + +The first frame renders an empty draft, and every session switch drops focus, selection, and whatever the user had typed. + +## `useLayoutEffect` is not a default + +`useLayoutEffect` is not used by default, and replacing it with `useEffect` is not a fix, because the two differ in timing rather than in what the code is doing. A layout measurement first answers why it happens here, why CSS cannot express it, and why it must be a pixel measurement; only then does it move into an injected measurement hook with the owning domain expert's sign-off. An effect of either kind never records state or binds logic, because that belongs in Injection. + +- Judged by purpose, not by hook name: which measurement or which binding the effect exists for. A state-recording or logic-binding effect returns to Injection as a Provider or an injected hook; only a measurement that survived the three questions stays, and its result is consumed as a declarative layout parameter. +- Not covered: the timing difference between the two effects, which `@types/react` documents for `useLayoutEffect` as synchronous after DOM mutation and before paint, is background fact and not a license to rewrite one as the other. +- Exception: any retained `useLayoutEffect` needs the owning domain expert's confirmation, and the change records that confirmation. +- Fix: express the layout in CSS — grid, `aspect-ratio`, a container query — or collect the measurement inside the injected hook and expose one value. + +Layout is declared in CSS, and the one measurement that survived the questions lives in an injected hook: + +```tsx +// Fix 1 — express the layout in CSS and delete the measurement. +// .card { display: grid; grid-template-columns: minmax(0, 1fr) auto; aspect-ratio: 16 / 9; } + +// Fix 2 — the approved measurement is injected and returns one value. +function CardBase({ useLayoutMeasurements }: Props) { + const ref = useRef(null) + const { gutterWidth } = useLayoutMeasurements(ref) + return
+} +``` + +Both of the first two effects below answer the wrong question — which effect to use — and the third parks a binding in an effect: + +```tsx +// A synchronous pixel measurement the design did not need. +useLayoutEffect(() => { setHeight(ref.current.getBoundingClientRect().height) }, []) + +// The same measurement with the hook name swapped. +useEffect(() => { setHeight(ref.current.getBoundingClientRect().height) }, []) + +// Logic binding parked in an effect instead of Injection. +useEffect(() => { bindShortcut(handler) }, []) +``` + +The rename leaves the layout problem in place, the measured height now depends on which effect was chosen, and the shortcut binds one render late instead of through Injection. + +## Effects never push render-derived data to a parent + +An effect body never calls a setter that arrived through props or slot props to push data back to a parent when that data could be decided during render from props or state the component already has. Such a push costs a second render, leaves the parent without the value on its first frame, and turns any later state change into a render cascade. The value is decided during render, the host takes it through a render-time channel, or a user gesture updates its own state in its handler. + +- Judged by: a setter from props or slot props called inside an effect, whose argument derives from the current props or state, with no external event or subscription callback driving it. A cleanup `return () => setX(undefined)` is part of the same violation. +- Consequences to name in review: the extra render pass; the parent's first frame missing the value; a dependency array that goes stale when a member is omitted; the effect → parent state → render cascade; and a child lifecycle entangled with parent state. +- Not covered: an effect that reacts to a genuine external event — a subscription callback, a socket frame, a user gesture — is not this pattern, and an event handler may update state directly. +- Exception: none by default; any exception needs the owning domain expert's confirmation. +- Fix, in order: render the UI where the state already is; if the host must render it, hand the value over during render; if the state is shared across entries, move it to the inject face or a declared store. + +The control is rendered where its state already lives, and the shared variant is read during render while the write stays in the gesture handler: + +```tsx +// 1. Render it where the state already lives; no channel is needed. +return + +// 2. The host renders it: the flag is declared state, read during render +// and written only from the gesture handler. +const { showOriginal } = useStore() +const { toggleOriginal } = actions +return +``` + +`label` and `disabled` are render-derivable, so calling the parent's setter from an effect makes the parent render twice and still lack the header on its first frame: + +```tsx +useEffect(() => { + setHeaderAction({ label, disabled, onClick }) // render-derivable data pushed up + return () => { setHeaderAction(undefined) } +}, [label, disabled, setHeaderAction]) +``` + +The parent renders twice for data it could have had on the first pass, the header appears a frame late or flashes, and the hand-written dependency list decides when it is published. + +## Build and style follow their owners + +Every build adjustment for a client package goes through the tsdown plugin for that package, and a package does not grow its own build branch. Styling goes through CSS Modules, and only images, GIFs, and SVG are inlined — inlined into CSS rather than into JSX. Large text never appears inline in TSX, and a `style` object holds only runtime-computed values, because static values and style variants belong to CSS Module class names. + +- Judged by: a new build branch inside a package instead of the tsdown plugin; a literal color, a component library, or Tailwind in feature styling; base64 data or large text in TSX; and a `style` object carrying static values or a variant switch. +- Not covered: a runtime-computed value such as a measured gutter or a progress percentage may enter `style`, and a component-local custom property may carry a layout value. [Component rules](../web-styling.md#component-rules) own the tokens, CSS Module, and class-name rules. +- Exception: a new build branch or a new inline form is explained in review before it lands, and the owning package's rules decide whether it stays. +- Fix: move the variant into a CSS Module class switched by `className`, keep base64 in CSS produced by the build chain, and delete the large text constant from the TSX module. + +The component switches class names and data attributes, and the CSS Module owns the presentation: + +```tsx +import s from './card.module.css' + +// Style variants are class names, not style-object branches. +export function Card({ active, tone, children }: Props) { + return ( +
+ {children} +
+ ) +} +``` + +Large text and inlined binary data enter the module, and the `style` object encodes a variant the theme cannot override: + +```tsx +const HUGE = '...several KB of text...' // large text inline in the module + +// A variant switch and an inlined asset both sit in the style object. +
+``` + +The bundle carries text and binary data that belong in CSS and assets, diffs carry unreadable blobs, and a theme override can no longer reach that colour. + +## A key states what it identifies + +Pass a `key` only when it names which entity or instance an element represents, and state that identity — a `turnId`, for example, is unique inside one session. Most lists already have a stable business id and need no extra key. A key invented to silence a warning or to encode render order states an identity the data does not have, and an array index is never that identity, because an insert or delete shifts every following item onto a different element. + +- Judged by: whether the item's identity can be expressed by position or by a stable id. When a key is passed, the change states what makes it unique, and `key={index}` over a mutable list fails. +- Not covered: React's own requirement that a list's items carry keys still applies; that key is the item's stable business id and not a second key beside it. +- Exception: none. An ordinary change needs no sign-off, while the key semantics of a shared component are stated in review. +- Fix: use the entity's stable id, and when two rows genuinely share one id, fix the id source instead of mixing an index into the key. + +The key is the turn's own id, whose uniqueness inside a session is the identity the element represents: + +```tsx +// turnId is unique inside a session; the id is the identity. +export function TurnList({ turns }: Props) { + return ( +
    + {turns.map((turn) => )} +
+ ) +} +``` + +The index stands in for identity, so every following row changes identity when the list changes: + +```tsx +// The index stands in for identity. +{items.map((item, index) => ( + +))} +``` + +After an insert or a delete, each following row keeps its previous state on new data, so an expanded row or an in-progress edit appears on the wrong item. + +## Each shared layer keeps its own isolation level + +A feature absorbs its requirement in its own package, and how much separation it must keep from shared code depends on which shared layer that code belongs to. The violation to look for is a feature-specific concept placed in a shared layer, because every other consumer then has to understand a concept that is not theirs and the branch is hard to withdraw. + +| Shared code | Isolation a feature keeps | +|---|---| +| `ui-primitives`, the catalogued controls | Consume the catalogued control. A new control starts in the feature package and is promoted into the shared catalog only when a second consumer needs it. | +| `ui-layout` and the shell layout | Consume it; adapt in the feature package with a wrapper, an overridden style, or an added layer. | +| `ui-renderer` and the other render machinery | No value dependency from business code; extend only through the documented registration seams — slots, provide/inject-bound hooks, declared stores. | +| Another feature plugin | No runtime import and no re-export of its values. UI crosses through slots and behavior through injected services; shared runtime code lives in a narrow static owner such as `client/store`, `ui-primitives`, or a browser-safe utility package. | +| The data object layer (`client/connection`, `session-controller/client`, `client/store`) | Read through the documented surface; a feature's own concepts do not enter these objects or their stores. | +| The feature's own package | Unrestricted — the requirement is absorbed here, including its own rows, ordering, and presentation. | + +- Judged by: whether the diff puts a feature-specific branch, field, or concept into any layer but the feature's own package. +- Not covered: code the feature owns, and a change to a shared layer that is genuinely general rather than one feature's need. +- Exception: a shared-layer change carries the owning domain expert's confirmation and regresses every consumer; a GUI change runs the minimum check in [packages/client/AGENTS.md](../../packages/client/AGENTS.md). +- Fix: absorb the requirement in the feature package, or, when the need is genuinely general, promote it to the shared layer in its own change with the owner's sign-off. + +The feature owns its row rendering and its ordering rule, and the base component is consumed unchanged: + +```tsx +// The feature owns its rows; the base is consumed, not edited. +// packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +export function WorkspaceBrowser({ rows, onArchive }: Props) { + const ordered = [...rows].sort((a, b) => Number(b.archived) - Number(a.archived)) + return ( +
    + {ordered.map((row) => )} +
+ ) +} +``` + +A base component learns one feature's ordering rule, so every consumer inherits a concept that is not theirs: + +```tsx +// ui-layout: the base learns one feature's ordering rule. +export function BrowserList({ rows, ...rest }: Props) { + const ordered = [...rows].sort((a, b) => Number(b.archived) - Number(a.archived)) + return
    {ordered.map((row) => )}
+} +``` + +Every other consumer of `ui-layout` now has to understand an archive concept that only one feature has, and withdrawing the branch means auditing all of them. diff --git a/docs/defensive-patterns/client-components.zh.md b/docs/defensive-patterns/client-components.zh.md new file mode 100644 index 0000000000..6bd92e4e57 --- /dev/null +++ b/docs/defensive-patterns/client-components.zh.md @@ -0,0 +1,495 @@ +# 客户端组件规则 + +[English](client-components.md) | 中文 + +关于客户端组件、其状态与 effect 的规则。详细纪律见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md);Slot 设计正家是 [slots.zh.md](../subsystems/slots.zh.md),样式规则正家是 [web-styling.zh.md](../web-styling.zh.md)。以下每一节给出要求、评审判定方式、边界与例外,以及一正一反两个例子。 + +## 状态经 Injection 到达 + +Slot 的组件经**自己注册项**的 inject 面获得它渲染所需的一切——普通数据、action 回调,以及渲染器绑定成 `use` hook 的保留 `hooks` 隔间——而父组件的渲染点经派生的 `renderSlot` 传 owner 数据。它绝不先在顶层读一棵大全局状态再逐层手工传下去。把每个活值都放在那个面上,props 才能在没变时保持引用不变,组件也才能只订阅自己读取的那部分子状态。缺了这条,任何一个无关字段变化都会重渲整棵子树——长会话在流式事件下的掉帧、滚动与输入卡顿就是这么来的。 + +- 判定方式:在注册项与其渲染点上,作者都必须能回答两件事——被渲染组件订阅了哪些子状态,以及它收到的哪些 props 在多次渲染之间保持引用稳定。要盯住每个 prop 的可变性:每次渲染都重建的值(新对象、新数组、内联回调)即使内容没变也会向下传播重渲。 +- 不在范围内:组件自己的局部状态(折叠、悬停、草稿用的 `useState`)不属于注入内容;不订阅 React 之外任何东西的组件也不需要新席位。 +- 例外:新增 hook 席位会改动 Slot 契约,因此该改动要在 review 中说明影响面——哪些组件订阅什么;使用既有注入面不需要逐组件批准。 +- 修法:把注入收窄到该视图真正渲染的内容,每个读取项经一个 hook 席位暴露一个字段,用窄 action 集合取代 store 或 service 句柄。[组件输入](../subsystems/slots.zh.md#component-inputs) 正家负责 inject 工厂、标准 hook 席位,以及它们各自如何成为 prop。 + +注册项承载普通数据、一组窄 action 与一个 hook 席位;渲染点只传 owner 数据,组件只读它渲染的那个字段: + +```tsx +// The inject factory returns plain data and callbacks; hooks is the reserved compartment. +ctx.slots.register( + { + name: 'sidebar.right', + inject: () => ({ + rename, + hooks: { info: infoSource }, // bound by the renderer as useInfo() + }), + }, + SidebarRight, +) + +// The parent's render site passes owner data through the derived renderSlot. +{renderSlot('sidebar.right', { id })} + +function SidebarRight({ id, useInfo, rename }: Props) { + const { title } = useInfo() // subscribes to title only + return +} +``` + +顶层组件读了整个 view store 并把它作为一个 prop 传下去,于是渲染点再也回答不了上面两个问题: + +```tsx +function Sidebar() { + const view = useViewStore() // one top-level global read + return // the whole store travels down +} + +function SidebarRight({ view }: { view: ViewStore }) { + return {view.activePanel.title} +} +``` + +该 store 里任何字段变化都会重渲整个侧栏子树,而且绑定它的渲染点完全看不到这个组件订阅了什么。 + +## props 承载数据与回调,不承载 React 节点 + +prop 是数据或回调;React 节点经声明的 slot 传递。把元素作为 prop 值或注入成员传下去,会藏起一条 Slot 契约没有描述的第二条组合路径,把渲染控制权从注册面挪到调用点,并让一个包去拼装另一个包的标记。新增的节点型 prop 或注入成员一律禁止;需要宿主提供标记的位置,改为声明一个 child slot。 + +- 判定方式:组件的 props 类型、inject 工厂的返回值、或渲染点的 owner props 对象里出现 `ReactNode`、`ReactElement` 或 `() => JSX.Element`。 +- 不在范围内:已经发布的节点型字段继续可用。规则在改动波及它时触发,该改动把它改掉,而不是继续扩展它。 +- 例外:同一改动内无法完成改造时,由对应领域专家确认延期,且改动中记录该确认;作者不得自行确认。 +- 修法:在父级注册的 `children` 中声明该位置,用 `renderSlot` 渲染它,数据经子组件的 props 传入。[扩展规则](../subsystems/slots.zh.md#extension-rules) 正家负责哪个包可以声明和渲染哪个 slot。 + +头部位置是一个声明的 child slot,因此宿主只传数据,标记由注册组件自己决定: + +```tsx +// The card declares the header location; the host passes data only. +ctx.slots.register( + { + name: 'tool.call.toolview', + children: { header: { kind: 'single', scope: 'session' } }, + }, + ToolCard, +) + +function ToolCard({ title, renderSlot }: Props) { + return
{renderSlot('header', { title })}
+} +``` + +React 节点作为 prop 值越过边界,于是它所走的组合路径没有任何地方声明过: + +```tsx +type ToolCardProps = { + header?: React.ReactNode + footer?: React.ReactNode +} + +// The call site assembles another package's markup. +} /> +``` + +卡片的 Slot 契约不再描述它渲染什么,调用点继续握着渲染控制权,而徽章所在的包变成了传节点方的运行时引用。 + +## slot 注册物是稳定的 function component + +注册进 slot 的值是在模块作用域声明的稳定 function component。渲染期创建的组件函数每一轮都是新的组件类型,而 React 把变化的类型当作另一个组件:它卸载这棵子树并挂载一棵新的,展开的行、输入焦点与滚动位置随之重置。注册期参数经 props 到达组件,而不是靠闭包捕获渲染期变量。 + +- 判定方式:注册物在哪里创建。注册调用或渲染调用里的内联箭头、渲染函数体内定义的组件、以及用 `useCallback` 包住内联组件的写法都不合格;模块作用域的 `function` 声明合格。 +- 不在范围内:本条约束的是注册物的身份,不是它的复杂度。稳定组件照样可以读取注入的 props、hooks 与回调。 +- 例外:只有调用方确实想要这次重挂载时,渲染期创建组件才是对的,普通改动无需单独批准;Slot 契约形态的变更按「状态经 Injection 到达」一节描述的注入面 review 走。 +- 修法:把组件移到模块作用域,用注册面的各个 share 给它的 props 定型,让变化的值经 inject 面传入而不是走闭包。 + +注册项持有对模块作用域组件的稳定引用,变化的值经注入到达;局部组件由渲染点传入: + +```tsx +function PanelTitle({ title, count }: Props) { + return {title} ({count}) +} + +// Registration: the local slot's scope is declared, never a component. +ctx.slots.registerFactory( + { + name: 'panel', + scope: 'root', + slots: { title: { scope: 'root' } }, + inject: () => ({ title: 'Sessions', count: 3 }), + }, + Panel, +) + +// The render site passes the local component, keeping its identity stable. +renderFactorySlot('panel', { /* owner data */ }, { slots: { title: PanelTitle } }) +``` + +渲染调用里的内联箭头会在该次渲染运行时产生新的组件类型: + +```tsx +// A new component type each time this render runs. +renderFactorySlot('panel', { /* owner data */ }, { slots: { title: (p) => {p.title} } }) +``` + +React 看到的类型与上一次注册不同,于是重挂载该席位,并在一次无关更新中重置它的局部状态——展开态、焦点或滚动位置。 + +## 组件绝不接收 Cordis context + +`ctx` 属于 apply 世界:插件主体与闭包持有它的 `inject` 工厂。组件不接收 Cordis context、不导入 service 类去戳它、也不读 React context,因为业务组件看不到任何 context——渲染器的 binding context 是它内部的。裸 observable 只有在 Injection 把它包成 hook 之后才到达组件;直接自行订阅的组件持有一份 Injection 面上无人释放的生命周期。 + +- 判定方式:组件的参数或 props 类型里出现 `ctx` 或 `Context`;组件体内读 `observable.value`、调用 `subscribe`、或接 `useSyncExternalStore`;feature 的 `.tsx` 模块里导入 service 类。 +- 不在范围内:组件内部、不订阅 React 之外任何东西的行为 hook(计时器、媒体查询)不是注入内容,留在组件里。 +- 例外:无。`TabSlot` 里现存的直接订阅是该席位 owner 的整改工作,不是新代码的先例。 +- 修法:把该事实移进注册的 `inject`——纯数据与回调,裸 observable 放进保留的 `hooks` 隔间——或移进声明的 store,再经派生的席位读取。 + +Injection 绑定 observable,组件读一个派生的 hook,订阅生命周期由渲染器持有: + +```tsx +// The inject factory is the only place that closes over ctx. +const inject = () => ({ + hooks: { tab: tabStore.observable }, + action: { openTab }, +}) + +function TabView({ useTab, action }: Props) { + const { loaded } = useTab() // the renderer owns the subscription + return +} +``` + +整个 view store 被注入给组件自行挑选字段,模块级句柄则把实例生命周期从框架手里拿走: + +```tsx +// The component receives the store itself and picks its own fields. +const inject = () => ({ viewStore }) + +function SidebarHeader({ viewStore }: Props) { + return +} + +// Module-level handle: one identity shared by every plugin load. +export const paneStore = createPaneStore() +``` + +组件的依赖面不再可审计,而共享的模块身份活得比任何一次 scope 清理都久:两个 pane 会落到同一个实例上,而不是各自一个。 + +## 身份与初始状态都是刻意的 + +状态切换不重建控件、也不替换其 DOM 节点:实例与其 DOM 身份在状态变化之间保持同一,焦点、选中与进行中的草稿因此得以保留。初始状态在首个流程运行时即就绪——同一条路径既准备数据也建立状态——而不是之后由 effect 回填。组件接收 hooks、选择器与 action 回调,而不是整个 service。 + +- 判定方式:状态变化之后,控件实例与其 DOM 节点是否保持同一身份;出现卸载再挂载即违反。初始化路径同样判定:状态在首个流程内建立合格,从 effect 写入不合格。 +- 依赖面:hooks 读取、选择器选中子状态、action 回调写回。整个 service 作为 prop 或注入成员传入即不合格。 +- 范围:带内部实例与本地状态的输入与命令控件,以及由 factory 创建的业务视图。 +- 例外:确需重挂载(切换的数据集其实例状态不可迁移)时,在 review 中连同状态迁移方案一并说明;由评审该控件实例生命周期改动的评审者接受。 +- 修法:在首个流程建立状态的地方派生初始值,保持布局节点挂载、只变化其 props,并用三个窄 share 取代 service prop。 + +草稿随会话一起建立,composer 节点保持挂载,只有 props 随状态变化: + +```tsx +// The draft is built by the first flow; nothing fills it in later. +const draft = useDraftState(sessionId) +const { canSend } = useComposerSelectors(draft) + +// The node stays mounted; state changes reach it as props. +return ( +
+ {draft.text} +
+) +``` + +初始状态迟一个提交到达,带 key 的控件在每次会话切换时被重建: + +```tsx +// The draft is filled in after the first commit. +useEffect(() => { setDraftState(initialDraft) }, []) + +// The keyed composer unmounts on every session switch. + +``` + +首帧渲染出空草稿,每次会话切换都会丢掉焦点、选中,以及用户已经输入的内容。 + +## `useLayoutEffect` 不是默认选项 + +`useLayoutEffect` 默认不用,换成 `useEffect` 也不算修好,因为二者的差别在时序,而不在代码在做什么。布局测量先回答三个问题——为何在此处发生、为何 CSS 无法表达、为何必须是像素测量;只有回答完,才收进注入的测量 hook 并取得对应领域专家的确认。两类 effect 都不承担状态记录或逻辑绑定,那是 Injection 的职责。 + +- 判定依据是用途而不是 hook 名字:这个 effect 为哪次测量或哪次绑定而存在。状态记录或逻辑绑定的 effect 回到 Injection——Provider 或注入 hook;只有通过三问的测量才留下,其结果作为声明式布局参数消费。 +- 不在范围内:两个 effect 的时序差异(`@types/react` 记载 `useLayoutEffect` 在 DOM 变更后同步触发、绘制前刷出)是背景事实,不是把其一改写为另一的许可。 +- 例外:任何保留下来的 `useLayoutEffect` 都要经对应领域专家确认,改动中记录该确认。 +- 修法:用 CSS 表达布局——grid、`aspect-ratio`、容器查询——或把测量收进注入 hook 并暴露一个值。 + +布局用 CSS 声明,唯一通过三问的测量住在注入 hook 里: + +```tsx +// Fix 1 — express the layout in CSS and delete the measurement. +// .card { display: grid; grid-template-columns: minmax(0, 1fr) auto; aspect-ratio: 16 / 9; } + +// Fix 2 — the approved measurement is injected and returns one value. +function CardBase({ useLayoutMeasurements }: Props) { + const ref = useRef(null) + const { gutterWidth } = useLayoutMeasurements(ref) + return
+} +``` + +下面前两个 effect 回答的是错的问题——该用哪个 effect——第三个把绑定停在 effect 里: + +```tsx +// A synchronous pixel measurement the design did not need. +useLayoutEffect(() => { setHeight(ref.current.getBoundingClientRect().height) }, []) + +// The same measurement with the hook name swapped. +useEffect(() => { setHeight(ref.current.getBoundingClientRect().height) }, []) + +// Logic binding parked in an effect instead of Injection. +useEffect(() => { bindShortcut(handler) }, []) +``` + +改名并不能解决布局问题,量到的高度从此取决于选了哪个 effect,而快捷键绑定迟一轮渲染生效,没有回到 Injection。 + +## effect 绝不把渲染期可派生的数据推给父层 + +effect 体内绝不调用经 props 或 slot props 传来的 setter,把本可在渲染期由组件已有的 props / state 决定的数据推回父层。这种推送要多付一轮渲染、让父层首帧拿不到值,并把之后任何一次状态抖动放大成渲染级联。该值在渲染期决定,宿主经渲染期通道取得,或由用户手势在其处理器内更新自己的状态。 + +- 判定方式:effect 体内调用了 props 或 slot props 传来的 setter,其入参由当前 props / state 派生,且没有外部事件或订阅回调驱动它。清理函数 `return () => setX(undefined)` 属于同一违规。 +- review 中要点名的后果:多一轮渲染;父层首帧缺值;依赖数组漏项即 stale;effect → 父 state → 再渲染的级联;子组件生命周期与父层状态纠缠。 +- 不在范围内:响应真实外部事件的 effect——订阅回调、socket 帧、用户手势——不是本形态;事件处理器可以直接更新状态。 +- 例外:默认无例外;任何例外都要经对应领域专家确认。 +- 修法按顺序:状态在哪里就让 UI 在哪里渲染;必须由宿主渲染时,在渲染期把值交出去;跨条目共享的状态移进注入面或声明的 store。 + +控件在状态所在处渲染,共享的开关在渲染期读取,写回只发生在手势处理器里: + +```tsx +// 1. Render it where the state already lives; no channel is needed. +return + +// 2. The host renders it: the flag is declared state, read during render +// and written only from the gesture handler. +const { showOriginal } = useStore() +const { toggleOriginal } = actions +return +``` + +`label` 与 `disabled` 都是渲染期可派生的,从 effect 调用父层 setter 让父层渲染两轮、首帧仍然没有头部动作: + +```tsx +useEffect(() => { + setHeaderAction({ label, disabled, onClick }) // render-derivable data pushed up + return () => { setHeaderAction(undefined) } +}, [label, disabled, setHeaderAction]) +``` + +父层为本来第一轮就能拿到的数据渲染两轮,头部迟一帧出现或闪烁,而发布时间由手写的依赖数组决定。 + +## 构建与样式各归其主 + +客户端包的每项构建调整都经该包的 tsdown 插件完成,包内不新开自己的构建分支。样式走 CSS Module,只有图像、GIF 与 SVG 需要内联——且内联到 CSS 而不是 JSX。TSX 里绝不内联大文本,`style` 对象只放运行期计算的值,因为静态值与多样式切换属于 CSS Module 的 class name。 + +- 判定方式:包内新开构建分支而不是走 tsdown 插件;feature 样式里出现字面颜色、组件库或 Tailwind;TSX 里出现 base64 数据或大文本;`style` 对象承载静态值或样式切换。 +- 不在范围内:运行期计算的值(量到的 gutter、进度百分比)可以进 `style`,组件局部自定义属性也可以承载布局值。[组件规则](../web-styling.zh.md#component-rules) 正家负责 token、CSS Module 与 class name 规则。 +- 例外:新增构建分支或新的内联形态在落地前于 review 中说明理由,由所属包的规则决定是否保留。 +- 修法:把样式切换移进用 `className` 切换的 CSS Module class,base64 交给构建链写进 CSS,并从 TSX 模块里删掉大文本常量。 + +组件切换 class name 与 data 属性,呈现由 CSS Module 负责: + +```tsx +import s from './card.module.css' + +// Style variants are class names, not style-object branches. +export function Card({ active, tone, children }: Props) { + return ( +
+ {children} +
+ ) +} +``` + +大文本与内联二进制数据进了模块,`style` 对象还编码了主题无法覆盖的样式切换: + +```tsx +const HUGE = '...several KB of text...' // large text inline in the module + +// A variant switch and an inlined asset both sit in the style object. +
+``` + +包里带上了本该属于 CSS 与资源的文本和二进制数据,diff 里躺着无法阅读的 blob,主题覆盖也再够不到那个颜色。 + +## 传 key 就要说明它标识什么 + +只有当 `key` 指明元素代表哪个实体或实例时才传它,并把这份身份说清楚——例如 `turnId` 在一个 session 内唯一。多数列表本就有稳定的业务 id,不需要额外的 key。为消除警告或编码渲染顺序而造的 key,声明的是一份数据并不具备的身份;数组下标从来不是这份身份,因为插入或删除会把后面每一项挪到另一个元素上。 + +- 判定方式:条目的身份能否由位置或稳定 id 表达。传 key 时,改动要说明它因何唯一,而可变列表上的 `key={index}` 不合格。 +- 不在范围内:React 要求列表项带 key 这条规则依然成立;那个 key 就是条目的稳定业务 id,不是它旁边的第二个 key。 +- 例外:无。普通改动无需批准,公共组件的 key 语义要在 review 中说明。 +- 修法:使用实体的稳定 id;两行确实共用一个 id 时,修 id 的来源,而不是往 key 里掺下标。 + +key 就是 turn 自己的 id,它在 session 内唯一,正是元素所代表的身份: + +```tsx +// turnId is unique inside a session; the id is the identity. +export function TurnList({ turns }: Props) { + return ( +
    + {turns.map((turn) => )} +
+ ) +} +``` + +下标冒充身份,于是列表一变,后面每一行的身份都跟着变: + +```tsx +// The index stands in for identity. +{items.map((item, index) => ( + +))} +``` + +插入或删除之后,后面每一行都会把先前的状态带到新数据上,展开的行或编辑中的内容会出现在错误的条目上。 + +## 每层共享代码各有自己的隔离度 + +功能把诉求吸收在自己的包里;它需要与共享代码保持多大隔离,取决于那段代码属于哪一层共享。要盯的违规形态是把业务专属概念放进共享层,因为此后每个使用方都得理解一个不属于自己的概念,而这个分支很难撤回。 + +| 共享代码 | 功能应保持的隔离度 | +|---|---| +| `ui-primitives`,目录中的控件 | 消费目录中的控件。新控件先在功能包内落地,只有出现第二个使用方时才提升进共享目录。 | +| `ui-layout` 与 shell 布局 | 消费它;在功能包内通过 wrapper、覆盖样式或加一层来适配。 | +| `ui-renderer` 及其他渲染机制 | 业务代码不产生值依赖;只经文档化的注册面扩展——slots、provide/inject 绑定的 hooks、声明的 store。 | +| 另一个功能插件 | 不运行时导入、也不重新导出它的值。UI 经 slots 跨越,行为经注入的服务跨越;共享运行时代码住在窄的静态正家,如 `client/store`、`ui-primitives` 或浏览器安全的工具包。 | +| 数据对象层(`client/connection`、`session-controller/client`、`client/store`) | 经文档化接口读取;功能自己的概念不进入这些对象及其 store。 | +| 功能自己的包 | 不受限——诉求在这里被吸收,包括它自己的行、排序与呈现。 | + +- 判定方式:diff 是否把业务专属的分支、字段或概念放进了功能自己包以外的任何一层。 +- 不在范围内:功能自己拥有的代码;以及确实属于通用需求、而不是某一个功能需要的共享层改动。 +- 例外:共享层的改动要带上所属领域专家的确认,并回归所有使用方;GUI 改动按 [packages/client/AGENTS.md](../../packages/client/AGENTS.md) 的最低检查自验。 +- 修法:把诉求吸收进功能包;确属通用需求时,在独立改动中连同 owner 的确认一起提升到共享层。 + +功能自己拥有行渲染与排序规则,基座组件按原样消费: + +```tsx +// The feature owns its rows; the base is consumed, not edited. +// packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +export function WorkspaceBrowser({ rows, onArchive }: Props) { + const ordered = [...rows].sort((a, b) => Number(b.archived) - Number(a.archived)) + return ( +
    + {ordered.map((row) => )} +
+ ) +} +``` + +基座组件学会某一个功能的排序规则,于是每个使用方都继承了一个不属于自己的概念: + +```tsx +// ui-layout: the base learns one feature's ordering rule. +export function BrowserList({ rows, ...rest }: Props) { + const ordered = [...rows].sort((a, b) => Number(b.archived) - Number(a.archived)) + return
    {ordered.map((row) => )}
+} +``` + +`ui-layout` 的每个其他使用方从此都要理解只有那一个功能才有的归档概念,撤回该分支意味着把它们全部审一遍。 diff --git a/docs/defensive-patterns/conversation-and-session.i18n.yaml b/docs/defensive-patterns/conversation-and-session.i18n.yaml new file mode 100644 index 0000000000..210cfe8b06 --- /dev/null +++ b/docs/defensive-patterns/conversation-and-session.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for conversation-and-session.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/defensive-patterns/conversation-and-session.md +/conversation-and-session-rules: + en: 499609ba1c6822d8 + zh: 7429eabcce7d7c14 +/conversation-and-session-rules/projection-is-the-only-read-and-compute-path: + en: 9fa9a332c93ba386 + zh: ce01ccb47dfa5df7 +/conversation-and-session-rules/cold-reads-are-the-last-resort: + en: 32991da738e47f7f + zh: b138910f8c16d103 +/conversation-and-session-rules/retention-follows-one-lifecycle-rule: + en: fcb40a31d0881e84 + zh: 779bc49cce81651e +/conversation-and-session-rules/node-computation-is-incremental-and-streaming-publication-is-batched: + en: 315c7ac2186d0721 + zh: 06bb4e072b242fe1 +/conversation-and-session-rules/the-node-build-and-grouping-engine-carries-no-business-rule: + en: ebfa96408f32c3c1 + zh: fb854b2b3e37aa9b +/conversation-and-session-rules/the-node-list-and-seat-components-carry-no-business-branch: + en: f69c4c16f472adcc + zh: 4570a62aa3d934fb +/conversation-and-session-rules/the-markdown-pipeline-carries-no-business-branch: + en: b124a7515404ed9e + zh: 4b544c2d1bbb6e76 +/conversation-and-session-rules/the-user-message-model-and-rendering-carry-no-business-field: + en: 8f97bbeaba14be0d + zh: cfad05711395cbd2 diff --git a/docs/defensive-patterns/conversation-and-session.md b/docs/defensive-patterns/conversation-and-session.md new file mode 100644 index 0000000000..7b91d6a676 --- /dev/null +++ b/docs/defensive-patterns/conversation-and-session.md @@ -0,0 +1,316 @@ +# Conversation and Session rules + +English | [中文](conversation-and-session.zh.md) + +Rules for reading a Session, holding one, computing conversation nodes, and rendering them. The data model and per-type semantics live in [session-projection.md](../subsystems/session-projection.md) and [conversation.md](../subsystems/conversation.md). + +## Projection is the only read and compute path + +Session data is read and computed through Projection. A client reads a projection value instead of pulling the Session log, and a computation that belongs to the Session runs as a registered projection unit that publishes its result instead of being recomputed on demand. Two answers to the same question — one pulled, one pushed — drift apart, so a render can show a value the Session log no longer supports. + +- Judged condition: every Session-derived value a client renders arrives through a projection read at the render position, and every new in-Session computation is registered as a unit whose result reaches clients as a published value. A component that fetches Session data itself, or a computation a reader triggers on demand, is the violation. +- State and View: a unit folds host-side State and declares the client-visible View over it, and the registration states that division. The `ProjectionDefinition` fields and the registry that drives them belong to [session-projection.md](../subsystems/session-projection.md#ctxsessionprojections--sessionprojectionregistry). +- Version: `stateVersion` starts at 1 and is bumped whenever the serialized state fields or the fold semantics change, compatible or not. Prefer a compatible change where one exists, but still bump; registering one key twice with different `stateVersion` values throws instead of merging. +- Compute strategy: units compute in full by default. Narrowing what a unit computes needs the owning domain expert's confirmation, never the implementer's own decision. +- Boundary: a partial projection view may serve interface availability for individual keys, such as a cached block drawn before the connected Session answers. It never decides an externally visible result, which waits for the standard projection value. + +A component that reads at its render position, and a computation that registers instead of being pulled, both comply: + +```tsx +// The client reads the published value; it never fetches Session data itself. +function PlanChip({ useProjection }: PlanChipProps) { + const plan = useProjection('plan') + return plan === undefined ? null : +} + +// The computation is a registered unit; the registry drives it and publishes changes. +const planDefinition: ProjectionDefinition<'plan'> = { + key: 'plan', + stateVersion: 1, + init: () => emptyPlan, + apply: (state, event) => foldPlan(state, event), + wire: { viewSchema: planSchema, view: planView }, +} +``` + +A component that pulls the same value over its own request breaks the single read path: + +```tsx +function PlanChip() { + const [plan, setPlan] = useState() + useEffect(() => { + void fetchSessionPlan(sessionId).then(setPlan) // a second read path beside Projection + }, [sessionId]) + return plan === undefined ? null : +} +``` + +The pulled copy and the pushed value disagree, and no version check can tell which one the Session currently holds. + +## Cold reads are the last resort + +Read an available cache before the original log, and reach for a cold read only when no projection covers the data. The "small Session may be read in full" exception is removed and is not reconsidered. When cached and official data are not equivalent, a missing or stale row produces an explicit outcome instead of silently switching onto the slow path. + +- Judged condition: a Session read consults the projection cache first, and an absent row leaves an explicit state for the caller; a cache test whose `else` branch opens the complete log is the violation. +- Fallback semantics: the caller decides what a miss means and states it in the returned value. There is no cache-size or Session-size exception, and no approver for one. +- Boundary: this restriction is the client's. An absolute "never fork a cold read" on the host is not required, because the host may need to open a log the client cannot. + +The stored projection rows are read first, and a miss stays a state the caller renders: + +```ts ignore-check +// The listing reads stored projection rows; a miss remains an explicit state. +type ListingView = + | { kind: 'cached'; snapshot: ProjectionSnapshot } + | { kind: 'unknown' } + +function readListing(header: SessionHeader, keys: readonly ProjectionKey[]): ListingView { + const cached = ctx.sessionProjectionCache.cachedSnapshot(header, keys) + return cached === undefined ? { kind: 'unknown' } : { kind: 'cached', snapshot: cached } +} +// Opening the complete log is a separate operation the caller names, never this fallback. +``` + +A cache test that falls through to the complete log turns the slow path into the ordinary one: + +```ts ignore-check +function readListing(header: SessionHeader, session: Session): ListingView { + const cached = ctx.sessionProjectionCache.cachedSnapshot(header) + if (cached !== undefined) return { kind: 'cached', snapshot: cached } + return coldReadCompleteLog(session) // every miss pays the full-log read +} +``` + +Every miss now reads the whole log in the foreground, so a slow Session is slow on every listing rather than only on the path that asks for it. + +## Retention follows one lifecycle rule + +A client holds a Session through an explicit reference (`SessionReference`), and the reference count reaching zero releases it. Idle reclamation follows the one lifecycle rule — no references and nothing in flight — and never enumerates which Sessions may be reclaimed from business state, because that list has to be maintained by every feature that adds one. An implicit holder is invisible, so reclamation cannot tell who still uses the Session. + +- Judged condition: the predicate is `refCount == 0 && inflight == 0`. Does the code hold the Session across its acquire → use window through a reference that retain and release can be audited against? A module-level Session object or a global current id is the violation. +- Boundary: the enforced parts are the unified predicate and the absence of business-state enumeration; the release trigger itself is checked by hand, so hand-checking alone is not a rejection reason. +- Ownership: `retain`, `release`, `ready`, and teardown ordering belong to [the Session Controller's client references](../../packages/api/session-controller/README.md#client-references). A source-session mention carried in a message is a different type, owned by [session-reference.md](../subsystems/session-reference.md). + +A view that retains through an explicit reference and releases it on teardown complies: + +```ts ignore-check +class SidebarSessionView implements Disposable { + private readonly reference: SessionReference + + constructor(readonly id: SessionId, sessions: ISessions) { + this.reference = sessions.retain(id, { source: 'sidebarView' }) + void this.reference.ready.catch((error: unknown) => { console.error('opening failed:', error) }) + } + + [Symbol.dispose](): void { + this.reference.release() // the last reference releases the Session + } +} +``` + +Module state that keeps the Session object hides every holder from the reclaimer: + +```ts ignore-check +let currentSessionId: SessionId | undefined +const sessionCache = new Map() + +export function remember(session: Session): void { + currentSessionId = session.id + sessionCache.set(session.id, session) +} + +export function reclaimIdle(): void { + if (state === 'idle' || state === 'closed') reclaim() // a newly added state is missed +} +``` + +Nobody can count who still uses the Session, and the next state added to the Session escapes the enumeration and is never reclaimed. + +## Node computation is incremental and streaming publication is batched + +A new node computation states its complexity, and high-frequency streaming chunks never publish immediately and never recompute in full. A per-chunk full rebuild published at once puts the whole node collection on the frame budget, and the transcript stalls behind the stream. The append-path cost — with `D` registered Definitions, `D` matches and one constant-time Context-key lookup — and the publication vocabulary (`none`, `animation-frame`, `immediate`) are owned by [conversation.md](../subsystems/conversation.md#definition-and-typed-chat-payload). + +- Judged condition: how much one chunk computes — the increment on the affected subtree, not the event window — and when its result is published — a batching policy, not one publication per chunk. +- Boundary: this rule binds the implementation; cadence alone is not a rejection reason, so a change is judged on the cadence it states and the published path it uses. +- Forbidden on the high-frequency path: `immediate` publication, and a full recomputation per chunk. +- Boundary: a structural or terminal change may publish `immediate`, because the cadence is chosen per publication. Only the high-frequency delta path is bound to batching. + +A chunk that updates one Context and asks for a coalesced publication complies: + +```ts ignore-check +const assistantDefinition: ConversationNodeDefinition = { + kind: 'assistant-step', + target: 'chat', + match: event => matchAssistant(event), + start: (_context, match) => initialState(match), + update: (context, match) => appendChunk(context.state, match.event), // increment, not the window + publication: () => 'animation-frame', // high-frequency deltas coalesce; never immediate + buildViewNode: context => assistantNode(context), +} +``` + +Rebuilding the whole node collection for every chunk and publishing it immediately: + +```ts ignore-check +function onChunk(chunk: AssistantLiveChunk): void { + const nodes = buildNodesFromCompleteWindow(session) // full recompute per chunk + target.publish(nodes, { publication: 'immediate' }) // and an immediate publish +} +``` + +Each chunk costs a full-window rebuild inside the frame budget, so grouping and rendering stall behind the stream. + +## The node build and grouping engine carries no business rule + +Business rules never enter the grouping engine, and a business requirement never reshapes the built payload. `ProcessState`, the group product hierarchy, and the set of group semantics in `conversation-nodes/process-groups.ts` are infrastructure: the engine understands enum placement values such as grouped, standalone, and reply, and the caller computes which one applies. A special case inside the engine changes the produced structure for every consumer and puts a business condition on the group computation path. + +- Judged condition: does the engine contain a business condition? A diff that adds a `ProcessState` field, adds or removes a group level, or changes group semantics for one business fails; the placement decision belongs to the caller that computes the enum value. +- Boundary: the layering, its cost, and existing business expectations are the reason a business does not rework the grouping design. +- Boundary: a new enum value carrying a new placement meaning is the supported extension, wherever the caller computes it. +- Exception and approver: none by default. A change to this infrastructure needs the corresponding domain expert's confirmation. [conversation.md](../subsystems/conversation.md#group-definitions) owns the group Definition types. + +The caller decides the business meaning and passes the placement enum the engine places by: + +```ts ignore-check +// Business judgment stays in the caller. +function placementOf(command: CommandView): NodePlacement { + return isHandoff(command) ? 'reply' : 'grouped' +} + +// The engine consumes the enum as data; it reads no business condition. +function groupEntries(commands: readonly CommandView[]): readonly NodePlacement[] { + return commands.map(placementOf) +} +``` + +A business condition inside the engine's own computation: + +```ts ignore-check +// The grouping engine asks what a node means, not where it goes. +function placeProcessNode(node: ChatConversationViewNode): GroupPlacement { + if (node.kind === 'command' && isRagMessage(node)) return 'rag' // business special case + return 'grouped' +} +``` + +The produced structure now carries one business's meaning, so every consumer and the group computation follow that feature. + +## The node list and seat components carry no business branch + +`ChatNodeList`, `ChatNodeSeat`, and `ChatGroupSeat` are infrastructure. They carry no business condition and no business-specific field, and a single business never changes the list traversal or the seat selection. A new node shape contributes its kind to `ChatNodeDataMap` and its renderer through the kind registration surface, so the list keeps dispatching on kind while business semantics stay in the business module. A branch added for one feature makes every node's rendering depend on that feature. + +- Judged condition: a business branch in a list or seat component, a business-only field on its props, or a traversal or seat-selection change that serves one business. +- Correct outlet: the business package merges its own key into `ChatNodeDataMap` and registers a renderer for the node kind; the seat renders the registered component for the node's kind. The registration discipline is owned by [slots.md](../subsystems/slots.md#extension-rules). +- Boundary: a rendering change that applies to every business — ordering, virtualization, accessibility — is not a business modification. +- Exception and approver: none by default; the corresponding domain expert confirms any exception. + +A business package that declares its kind and registers its renderer leaves the shared list untouched: + +```tsx +// The business declares its kind and registers its renderer; the shared list is untouched. +declare module '@deepseek-ai/dsh-client-ui-chat/client' { + interface ChatNodeDataMap { handoff: HandoffData } +} + +const HandoffNodeView = memo(function HandoffNodeView({ node, t }: ChatNodeOwnerProps<'handoff'>) { + return +}) + +ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'handoff', locale: NS }, HandoffNodeView)) +``` + +A business branch and a business field inside the shared seat: + +```tsx +export const ChatNodeSeat = memo(function ChatNodeSeat({ node, handoffFlag }: ChatNodeSeatProps) { + if (node.kind === 'user' && handoffFlag) return // business dispatch + return
{renderByKind(node)}
+}) +``` + +One feature's condition now decides how every node renders, and the shared seat must change again for the next feature. + +## The Markdown pipeline carries no business branch + +`AssistantMarkdown` and its rendering pipeline are infrastructure: no business condition enters the Markdown render path, and no business rewrites the node mapping or the style injection rules. A presentation need registers a renderer on the existing chain or expresses itself in the data, and the official default content participates in that same registration surface rather than existing as an optional renderer with a host-side inline fallback. A pipeline that hosts business special cases can no longer be reviewed or optimized on its own. + +- Judged condition: a business branch inside `AssistantMarkdown` or its block mapping — for example `if (isXxxBusiness) renderXxx()` — or a style injection rule keyed on one business. +- Correct outlet: a renderer registered on the existing chain, or a semantic field in the data that the generic renderer reads. The Component Factory registration is owned by the [ui-renderer README](../../packages/client/ui-renderer/README.md). +- Wrapping: a wrapper that needs the standard rendering calls the official entry and keeps its fallback; it never re-renders the slot and never imports the private React component across packages. +- Responsibility: the host owns position and invocation; the plugin owns its own state, cleanup, and controls, and the official host carries no business action for a contribution. +- `labels`: an optional complete `MarkdownLabels` object is used whole, with no field-level merge against the default; omitting it uses the official default. `variant="compact"` is fixed by the Factory. +- Boundary: generic Markdown capability — syntax support, performance — is not a business modification. +- Exception and approver: none by default; the corresponding domain expert confirms any exception. + +A contribution that registers into the existing single seat, and reuses the published Factory instead of re-rendering the standard content: + +```tsx +// The business contributes its own body into the seat the host already renders. +ctx.slots.inject('conversation.chat.reasoning.body', () => ctx.slots.register( + { name: 'conversation.chat.reasoning.body', scope: 'session', locale: NS }, + TranslatedReasoningBody, +)) + +// The contribution keeps the standard rendering by calling the published Factory. +function TranslatedReasoningBody({ text, running, renderFactorySlot, t }: ReasoningBodyProps) { + return ( + <> +

{t('reasoning.translated')}

+ {renderFactorySlot('conversation.chat.reasoning.content', { text, running })} + + ) +} +``` + +A business branch inside the shared Markdown pipeline: + +```tsx +export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, ...props }: AssistantMarkdownProps) { + return <>{blocks.map(block => ( + isHandoffBlock(block) + ? // a business special case in the pipeline + : renderBlock(block, props) + ))} +}) +``` + +Every business special case lands in one shared pipeline, and Markdown rendering can no longer change or be measured without the businesses it now carries. + +## The user message model and rendering carry no business field + +`UserMessage` and the UI's `user` kind rendering are infrastructure: no business-only field on the message model, and no business branch in the user message renderer. A business difference travels in the message payload — its own merge-extensible source kind or a content field — or through a registered renderer. A field added for one business is handled by every consumer of the model, and the next business adds another branch to the same shared renderer. + +- Judged condition: a business-named field on `UserMessage`, or a business condition in the rendering of the `user` kind. +- Correct outlet: expression in the payload. `MessageSourceMap` is declaration-merge extensible, so a producer declares its own source kind instead of widening `UserMessage`; the message model is documented in [session.md](../subsystems/session.md). +- Boundary: a generic message capability — attachments, quotes, edit state — is not a business modification. +- Exception and approver: none by default; the corresponding domain expert confirms any exception. + +A producer that declares its own source kind keeps the business difference in the payload: + +```ts ignore-check +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + handoff: { kind: 'handoff'; sessionId: SessionId } + } +} + +const message = createUserMessage({ + role: 'user', + content: [{ type: 'text', text: prompt }], + source: { kind: 'handoff', sessionId: source.id }, +}) +``` + +A business field on the shared model, and the branch it forces into the shared renderer: + +```ts ignore-check +interface UserMessage extends MessageBase { + readonly role: 'user' + readonly handoffKind?: 'handoff' | 'rag' // one business's field on the shared model +} + +// ...and the renderer branches on it. +if (message.handoffKind === 'handoff') return +``` + +Every consumer of the message model handles one business's field, and the shared renderer grows a branch for each business that follows. diff --git a/docs/defensive-patterns/conversation-and-session.zh.md b/docs/defensive-patterns/conversation-and-session.zh.md new file mode 100644 index 0000000000..855fdd876f --- /dev/null +++ b/docs/defensive-patterns/conversation-and-session.zh.md @@ -0,0 +1,316 @@ +# 会话与 Session 规则 + +[English](conversation-and-session.md) | 中文 + +关于读取 Session、持有 Session、计算会话节点与渲染它们的规则。数据模型与各类型语义见 [session-projection.zh.md](../subsystems/session-projection.zh.md) 与 [conversation.zh.md](../subsystems/conversation.zh.md)。 + +## Projection 是唯一的读取与计算路径 + +Session 数据一律经 Projection 读取与计算。客户端读投影值,而不是拉 Session 日志;属于该会话的计算以注册单元的形式运行并**推送**结果,而不是按需重算。同一个问题若有两个答案——一个拉取、一个推送——两者就会分叉,渲染可能显示出 Session 日志已不再支持的值。 + +- 判定条件:客户端渲染的每个 Session 派生值都在渲染位置经投影读取取得,每个新增的会话内计算都注册为单元、其结果以已发布值的形式到达客户端。组件自己拉取 Session 数据、或读者按需触发一次计算,都属违规。 +- State 与 View:单元折叠 host 侧 State,并在其上声明客户端可见的 View,二者分工写在注册里。`ProjectionDefinition` 的字段与驱动它们的注册表归 [session-projection.zh.md](../subsystems/session-projection.zh.md#ctxsessionprojections--sessionprojectionregistry)。 +- 版本:`stateVersion` 从 1 起,只要序列化 state 字段或折叠语义变化就自增,兼容与否都一样。存在兼容改法时优先兼容,但版本仍要自增;同一 key 以不同 `stateVersion` 注册两次会抛错,而不是合并。 +- 计算策略:单元默认全量计算。收窄某个单元的计算范围需要对应领域专家确认,绝不能由实现者自行决定。 +- 边界:非完全 projection 渲染可用于某些 key 的界面可用性展示,例如在连接中的 Session 应答之前先画缓存块。它绝不参与对外可见结果的判定,那些结果等标准 projection 值。 + +在渲染位置读取、并以注册代替拉取,两者都合规: + +```tsx +// The client reads the published value; it never fetches Session data itself. +function PlanChip({ useProjection }: PlanChipProps) { + const plan = useProjection('plan') + return plan === undefined ? null : +} + +// The computation is a registered unit; the registry drives it and publishes changes. +const planDefinition: ProjectionDefinition<'plan'> = { + key: 'plan', + stateVersion: 1, + init: () => emptyPlan, + apply: (state, event) => foldPlan(state, event), + wire: { viewSchema: planSchema, view: planView }, +} +``` + +自行发请求拉取同一个值,就破坏了唯一读面: + +```tsx +function PlanChip() { + const [plan, setPlan] = useState() + useEffect(() => { + void fetchSessionPlan(sessionId).then(setPlan) // a second read path beside Projection + }, [sessionId]) + return plan === undefined ? null : +} +``` + +拉取到的副本与推送值互相矛盾,版本检查也无法说明 Session 当前持有的是哪一个。 + +## 冷读是最后手段 + +可用的缓存要先于原始日志被读取;只有没有任何投影覆盖该数据时才走冷读。「小会话可以全读」这一例外已移除、不再考虑。当缓存与正式数据不完全对等时,缺失或过期要产出显式结果,而不是静默改走重路径。 + +- 判定条件:Session 读取先查 projection 缓存,行缺失时为调用方留下显式状态;缓存判断的 `else` 分支直接打开完整日志,即属违规。 +- 回退语义:缺失意味着什么由调用方决定,并写进返回值。不存在按缓存大小或 Session 大小开出的例外,也没有对应的批准人。 +- 边界:这条限制是前端的。host 侧「禁止 fork 冷读」的绝对化表述不成立,因为 host 可能需要打开前端打不开的日志。 + +先读已存储的投影行,缺失仍是一个由调用方渲染的状态: + +```ts ignore-check +// The listing reads stored projection rows; a miss remains an explicit state. +type ListingView = + | { kind: 'cached'; snapshot: ProjectionSnapshot } + | { kind: 'unknown' } + +function readListing(header: SessionHeader, keys: readonly ProjectionKey[]): ListingView { + const cached = ctx.sessionProjectionCache.cachedSnapshot(header, keys) + return cached === undefined ? { kind: 'unknown' } : { kind: 'cached', snapshot: cached } +} +// Opening the complete log is a separate operation the caller names, never this fallback. +``` + +缓存判断落回完整日志,就等于把慢路径变成常规路径: + +```ts ignore-check +function readListing(header: SessionHeader, session: Session): ListingView { + const cached = ctx.sessionProjectionCache.cachedSnapshot(header) + if (cached !== undefined) return { kind: 'cached', snapshot: cached } + return coldReadCompleteLog(session) // every miss pays the full-log read +} +``` + +现在每次缺失都在前台读完整日志,于是慢会话在每次列表读取时都慢,而不只是在那条主动请求它的路径上慢。 + +## 持有与回收遵循同一条生命周期判据 + +客户端经显式引用(`SessionReference`)持有 Session,引用计数归零即释放。闲置回收只看那一条生命周期判据——零引用且无在途——绝不按业务状态枚举「哪些 Session 可以回收」,因为那份清单要靠每个新增状态的业务功能自己去维护。隐式持有者不可见,回收便判断不了谁还在使用该 Session。 + +- 判定条件:判据 = `refCount == 0 && inflight == 0`。代码是否在「获取 → 使用」的时间窗口内通过一份可审计 retain / release 的引用来持有 Session?模块级 Session 对象或全局 current id 都属违规。 +- 边界:强制的是统一判据与不做业务状态枚举这两点;释放触发本身靠人工核对,因此仅凭人工核对不构成打回理由。 +- 归口:`retain`、`release`、`ready` 与 teardown 顺序归 [Session Controller 的 client references](../../packages/api/session-controller/README.zh.md#client-references)。消息里携带的 source session mention 是另一种类型,归 [session-reference.zh.md](../subsystems/session-reference.zh.md)。 + +经显式引用持有并在 teardown 释放的视图合规: + +```ts ignore-check +class SidebarSessionView implements Disposable { + private readonly reference: SessionReference + + constructor(readonly id: SessionId, sessions: ISessions) { + this.reference = sessions.retain(id, { source: 'sidebarView' }) + void this.reference.ready.catch((error: unknown) => { console.error('opening failed:', error) }) + } + + [Symbol.dispose](): void { + this.reference.release() // the last reference releases the Session + } +} +``` + +模块状态缓存 Session 对象,会把所有持有者对回收器隐藏: + +```ts ignore-check +let currentSessionId: SessionId | undefined +const sessionCache = new Map() + +export function remember(session: Session): void { + currentSessionId = session.id + sessionCache.set(session.id, session) +} + +export function reclaimIdle(): void { + if (state === 'idle' || state === 'closed') reclaim() // a newly added state is missed +} +``` + +没有人能数清谁仍在使用该 Session,下一个新增的 Session 状态也会漏出枚举、永远不被回收。 + +## 节点计算是增量的,流式发布是合批的 + +新增节点计算要说明其复杂度;高频流式 chunk 绝不立即发布、也不做全量计算。每个 chunk 全量重算并立即发布,等于把整个节点集合压进帧预算,转写区便会堵在流后面。追加速路径的开销——在 `D` 个已注册 Definition 下执行 `D` 次匹配与一次常量级 Context key 查找——以及发布节拍取值(`none`、`animation-frame`、`immediate`)归 [conversation.zh.md](../subsystems/conversation.zh.md#definition-and-typed-chat-payload)。 + +- 判定条件:单个 chunk 计算了多少——受影响子树的增量,而不是事件窗口——以及结果何时发布——合批策略,而不是每个 chunk 发布一次。 +- 边界:本条约束实现;节拍本身不构成打回理由,改动按它声明的节拍与所用的已发布路径评判。 +- 高频路径上禁止:`immediate` 发布,以及每个 chunk 做一次全量重算。 +- 边界:结构性或终态变化可以 `immediate` 发布,因为节拍是按单次发布选择的。只有高频 delta 路径受合批约束。 + +只更新一个 Context 并请求合批发布的 chunk 合规: + +```ts ignore-check +const assistantDefinition: ConversationNodeDefinition = { + kind: 'assistant-step', + target: 'chat', + match: event => matchAssistant(event), + start: (_context, match) => initialState(match), + update: (context, match) => appendChunk(context.state, match.event), // increment, not the window + publication: () => 'animation-frame', // high-frequency deltas coalesce; never immediate + buildViewNode: context => assistantNode(context), +} +``` + +每个 chunk 重建整个节点集合并立即发布: + +```ts ignore-check +function onChunk(chunk: AssistantLiveChunk): void { + const nodes = buildNodesFromCompleteWindow(session) // full recompute per chunk + target.publish(nodes, { publication: 'immediate' }) // and an immediate publish +} +``` + +每个 chunk 都要在帧预算内做一次全窗口重建,于是分组与渲染都堵在流后面。 + +## 节点构建与分组引擎不承载业务规则 + +业务规则绝不进入分组引擎,业务诉求也绝不改变构建产物的形态。`ProcessState`、分组产物层级,以及 `conversation-nodes/process-groups.ts` 里的分组语义集合都属基础设施:引擎只认枚举化的落位语义(如成组 / 独立 / 回复),由调用方算出该用哪一个。引擎内的业务特判会改变所有使用方共享的产物结构,并把业务条件带上分组计算路径。 + +- 判定条件:引擎内部是否存在业务条件?新增 `ProcessState` 字段、增删分组产物层级、为某个业务改变分组语义,都属违规;落位判定归算出枚举值的调用方。 +- 边界:架构分层、其开销与既有业务预期,就是业务不得重做分组设计的原因。 +- 边界:携带新落位含义的新枚举值才是受支持的扩展,无论调用方在哪里算出它。 +- 例外与批准人:无默认例外。改动该基础设施须经对应领域专家确认。分组 Definition 类型归 [conversation.zh.md](../subsystems/conversation.zh.md#group-definitions)。 + +调用方决定业务含义,并把引擎据以落位的枚举值传进去: + +```ts ignore-check +// Business judgment stays in the caller. +function placementOf(command: CommandView): NodePlacement { + return isHandoff(command) ? 'reply' : 'grouped' +} + +// The engine consumes the enum as data; it reads no business condition. +function groupEntries(commands: readonly CommandView[]): readonly NodePlacement[] { + return commands.map(placementOf) +} +``` + +业务条件写进引擎自己的计算过程: + +```ts ignore-check +// The grouping engine asks what a node means, not where it goes. +function placeProcessNode(node: ChatConversationViewNode): GroupPlacement { + if (node.kind === 'command' && isRagMessage(node)) return 'rag' // business special case + return 'grouped' +} +``` + +产物从此承载某一个业务的含义,所有使用方与分组计算都要跟着该功能走。 + +## 节点列表与席位组件不承载业务分支 + +`ChatNodeList`、`ChatNodeSeat` 与 `ChatGroupSeat` 属基础设施。它们不承载业务判断,也不承载业务专用字段;单一业务绝不改变列表遍历或席位选择。新的节点形态把自己的 kind 贡献给 `ChatNodeDataMap`,并经 kind 注册面注册 renderer,于是列表继续按 kind 分发,业务语义留在业务模块内。为某个功能加的分支,会让每个节点的渲染都依赖该功能。 + +- 判定条件:在列表或席位组件里加业务分支、给其 props 加业务专用字段,或为单一业务改变列表遍历与席位选择。 +- 正确出路:业务包把自己的 key merge 进 `ChatNodeDataMap`,并为该节点 kind 注册 renderer;席位渲染该 kind 已注册的组件。注册纪律归 [slots.zh.md](../subsystems/slots.zh.md#extension-rules)。 +- 边界:对所有业务生效的渲染增强——排序、虚拟化、可访问性——不属于为业务诉求修改。 +- 例外与批准人:无默认例外;任何例外须经对应领域专家确认。 + +声明自己的 kind 并注册 renderer 的业务包,不改动共享列表: + +```tsx +// The business declares its kind and registers its renderer; the shared list is untouched. +declare module '@deepseek-ai/dsh-client-ui-chat/client' { + interface ChatNodeDataMap { handoff: HandoffData } +} + +const HandoffNodeView = memo(function HandoffNodeView({ node, t }: ChatNodeOwnerProps<'handoff'>) { + return +}) + +ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'handoff', locale: NS }, HandoffNodeView)) +``` + +把业务分支与业务字段写进共享席位: + +```tsx +export const ChatNodeSeat = memo(function ChatNodeSeat({ node, handoffFlag }: ChatNodeSeatProps) { + if (node.kind === 'user' && handoffFlag) return // business dispatch + return
{renderByKind(node)}
+}) +``` + +某个功能的条件从此决定每个节点怎么渲染,下一个功能还得再改一次共享席位。 + +## Markdown 管线不承载业务分支 + +`AssistantMarkdown` 及其渲染管线属基础设施:Markdown 渲染路径里不得出现业务条件,也不得按业务改写节点映射或样式注入规则。展示需求要么在既有链上注册 renderer,要么在数据里表达;官方默认内容也参与同一个注册面,而不是做成「可选 renderer + 宿主内联 fallback」。承载业务特判的管线,再也无法单独评审或单独优化。 + +- 判定条件:`AssistantMarkdown` 或其 block 映射里出现业务分支——例如 `if (isXxxBusiness) renderXxx()`——或按某个业务注入样式规则。 +- 正确出路:在既有链上注册 renderer,或在数据里带上语义字段、由通用 renderer 读取。Component Factory 的注册归 [ui-renderer README](../../packages/client/ui-renderer/README.zh.md)。 +- 包装展示:需要标准渲染的包装组件调用官方入口并保留其 fallback;绝不重复渲染 Slot,也绝不跨包导入官方私有 React 组件。 +- 权责边界:宿主只负责位置与调用;插件的状态、清理、控件归插件自己,官方宿主不为某个 contribution 承载业务动作。 +- `labels`:可选传入完整 `MarkdownLabels`,**传入即整体使用**、不与默认值做字段级合并;省略则用官方默认。`variant="compact"` 由 Factory 固定。 +- 边界:通用 Markdown 能力——语法支持、性能优化——不属于为业务诉求修改。 +- 例外与批准人:无默认例外;任何例外须经对应领域专家确认。 + +注册进既有单席位、并复用已发布 Factory(而不是自己重渲标准内容)的 contribution: + +```tsx +// The business contributes its own body into the seat the host already renders. +ctx.slots.inject('conversation.chat.reasoning.body', () => ctx.slots.register( + { name: 'conversation.chat.reasoning.body', scope: 'session', locale: NS }, + TranslatedReasoningBody, +)) + +// The contribution keeps the standard rendering by calling the published Factory. +function TranslatedReasoningBody({ text, running, renderFactorySlot, t }: ReasoningBodyProps) { + return ( + <> +

{t('reasoning.translated')}

+ {renderFactorySlot('conversation.chat.reasoning.content', { text, running })} + + ) +} +``` + +业务分支写进共享 Markdown 管线: + +```tsx +export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, ...props }: AssistantMarkdownProps) { + return <>{blocks.map(block => ( + isHandoffBlock(block) + ? // a business special case in the pipeline + : renderBlock(block, props) + ))} +}) +``` + +每个业务特判都落进同一条共享管线,Markdown 渲染再也无法脱离它如今承载的这些业务去改动或度量。 + +## 用户消息模型与渲染不承载业务字段 + +`UserMessage` 与 UI 侧 `user` kind 渲染属基础设施:消息模型上不得有业务专用字段,用户消息渲染里不得有业务分支。业务差异经消息 payload 传递——自己可 merge 扩展的 source kind 或某个 content 字段——或经注册的 renderer 表达。为某个业务加的字段,所有消息模型使用方都要处理;下一个业务还会往同一个共享 renderer 里再加一条分支。 + +- 判定条件:`UserMessage` 上出现业务命名字段,或 `user` kind 的渲染里出现业务条件。 +- 正确出路:在 payload 里表达。`MessageSourceMap` 可经 declaration merging 扩展,因此生产者声明自己的 source kind,而不是加宽 `UserMessage`;消息模型见 [session.zh.md](../subsystems/session.zh.md)。 +- 边界:通用消息能力——附件、引用、编辑态——不属于为业务诉求修改。 +- 例外与批准人:无默认例外;任何例外须经对应领域专家确认。 + +声明自己的 source kind 的生产者,把业务差异留在 payload 里: + +```ts ignore-check +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + handoff: { kind: 'handoff'; sessionId: SessionId } + } +} + +const message = createUserMessage({ + role: 'user', + content: [{ type: 'text', text: prompt }], + source: { kind: 'handoff', sessionId: source.id }, +}) +``` + +共享模型上的业务字段,以及它逼进共享 renderer 的分支: + +```ts ignore-check +interface UserMessage extends MessageBase { + readonly role: 'user' + readonly handoffKind?: 'handoff' | 'rag' // one business's field on the shared model +} + +// ...and the renderer branches on it. +if (message.handoffKind === 'handoff') return +``` + +消息模型的每个使用方都要处理某一个业务的字段,共享 renderer 则随每个后续业务再长一条分支。 diff --git a/docs/defensive-patterns/general.i18n.yaml b/docs/defensive-patterns/general.i18n.yaml new file mode 100644 index 0000000000..eccdc2015d --- /dev/null +++ b/docs/defensive-patterns/general.i18n.yaml @@ -0,0 +1,52 @@ +# Bilingual-pair consistency record for general.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/defensive-patterns/general.md +/defensive-patterns: + en: 324de84cf5239e89 + zh: b3bdfb17d01e5a6c +/defensive-patterns/report-orthogonal-outcomes-independently: + en: c16f636f8f522db4 + zh: 94c523b536df786d +/defensive-patterns/honor-public-contracts-on-both-sides: + en: 656746d485640b3a + zh: f75cd0333dc5bc9b +/defensive-patterns/async-state-is-not-synchronous-state: + en: f58b3f3fb047f434 + zh: daea224503617808 +/defensive-patterns/timing-does-not-carry-correctness: + en: 5778664b676a0079 + zh: e99aeabf0631f647 +/defensive-patterns/optional-inputs-state-both-behaviors: + en: 243e275fee74f54e + zh: a58e04b6e3ac0e7a +/defensive-patterns/one-value-states-one-fact: + en: 598d9041ccfaba7c + zh: 2ac9f98c97015f66 +/defensive-patterns/a-new-active-current-singleton-is-refused: + en: 8bf36acf96fa4c33 + zh: bb6db31a63bcf383 +/defensive-patterns/shared-state-has-one-owner: + en: 053e2c485eec2613 + zh: 2817da00572c789f +/defensive-patterns/a-business-capability-owns-itself: + en: 7588b8dba9279dac + zh: 2760468ede60220c +/defensive-patterns/changes-carry-performance-evidence: + en: 0d6855d1ae0ef4ae + zh: 0f771999398ce052 +/defensive-patterns/migrations-preserve-behavior: + en: 6213e051f51437b8 + zh: 853580b3a4fec100 +/defensive-patterns/dispose-must-reach-quiescence-not-just-request-it: + en: eeb9fe3158484618 + zh: 97187d4dbe4d75ee +/defensive-patterns/contain-callback-exceptions-in-the-dispatcher: + en: ae2332afa54cb253 + zh: db51fedb070d9a83 +/defensive-patterns/never-hand-untrusted-output-the-ambient-environment-or-predictable-paths: + en: 6fb7938f5197f520 + zh: 01eb670a852f24e6 +/defensive-patterns/unlink-link-shaped-paths: + en: 35944dc084b41ded + zh: 0e59beebd9fb6d22 diff --git a/docs/defensive-patterns/general.md b/docs/defensive-patterns/general.md new file mode 100644 index 0000000000..b1f0bba7b9 --- /dev/null +++ b/docs/defensive-patterns/general.md @@ -0,0 +1,222 @@ +# Defensive patterns + +English | [中文](general.zh.md) + +Hard-won bug-class rules: each pattern below is a class of defect that actually shipped or nearly shipped here, stated as the rule that prevents its recurrence. Read this before writing lifecycle, concurrency, subprocess, or teardown code. Test-tier counterparts (real entry path, world-verification, resource ownership) are in [testing.md](../testing.md); the type-level definitions these rules refer to live in [subsystems/](../subsystems/README.md). + +## Report orthogonal outcomes independently + +A result can be several things at once — a process can time out AND exit 0 because it trapped the signal. Surface each independent fact (`timedOut`, `signal`, `exitCode`) on its own; never nest one flag's report inside another's branch, or a caller reads a cut-short run as a clean success. + +A reviewer checks the reporting shape rather than the killing mechanism: can each fact be true independently, and is each one readable without entering another condition's branch? The boundary is the report — how the process is signalled or how long it is given is a separate decision. + +## Honor public contracts on BOTH sides + +When an implementation receives several representations of one outcome, normalize them before returning through the public API. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmRuntime.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. + +The judged condition is whether a consumer can name the source of a failure from the public API alone. Internal helpers stay free to throw: the requirement binds the boundary the consumer crosses, not every function behind it. + +## Async state is not synchronous state + +`agent.followup()` has no per-message completion or result; a background job's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never treat `agent/status` or `whenIdle()` as the result of one follow-up: several queued follow-ups, steering, and injected work may share one `running` interval, while cancellation or disposal can discard unstarted items. An automation caller that truly owns a run must define its interval explicitly—for example, from its message's durable inbox receipt through the next whole-agent `idle`—and describe any selected output as interval-wide rather than causally attributed to that message. The guard cuts both ways: if the awaited transition can never occur, the wait hangs, so handle the "nothing to wait for" branch explicitly. + +The judged condition is whether an agent can observe a per-unit completion the API does not define. Where the interval genuinely belongs to the caller, it is the caller that states the interval and the output scope; a shared `running` flag is never that statement. + +## Timing does not carry correctness + +Correctness comes from explicit call points and explicit state, never from where a callback lands in the event loop. A `microtask`, `setTimeout`, or `queueMicrotask` that decides whether work is correct races the rest of the program: it holds under a light load and silently fails under a busy one or when batching changes. React code follows the same rule — an effect never supplies a "just after render" ordering that the design should have made explicit. + +The judged condition is whether correctness depends on "that callback already ran" instead of on a named call point or state machine. Two boundaries: a test's timeout budget waits for a failure and is not timing-dependent correctness, and a deliberate delay that only paces user-visible work is not this rule. There is no developer-side exception; a genuine need for a timing technique goes to the domain owner. + +```ts +// The order is written into the code: dispose the old store, then build its successor. +declare let store: { dispose(): void } +declare function createStore(scopeKey: string): { dispose(): void } +declare const scopeKey: string + +store.dispose() +store = createStore(scopeKey) +``` + +```ts ignore-check +queueMicrotask(() => { if (!ready) refresh() }) // assumes the microtask runs before the next frame +setTimeout(() => setMounted(true), 0) // waits for a render that was never sequenced +``` + +The wrong form fails intermittently: a dropped refresh or a state that never syncs, reproducing only under load. + +## Optional inputs state both behaviors + +An optional parameter states what the callee does when it is present and when it is absent, so a caller reads both behaviors from the contract instead of inferring one from a defensive operator. Accumulating `?.` is the signal: each one substitutes for a decision about whether the field should exist at all. + +The judged condition is whether `?.` replaces a determination that a field exists rather than handling a genuinely optional hop on a known value. The requirement binds the interface contract: both branches state their return value and their side effects, and the contract is where the caller reads them. There is no exception channel — a call site that cannot state both behaviors is a review blocker, not a style preference. + +```ts +// Both behaviors are in the contract: no options object means "unknown", not "skip". +interface ResolveInput { options?: readonly string[] } +type Resolution = { kind: 'unknown' } | { kind: 'value'; value: string } + +function resolve(input: ResolveInput): Resolution { + if (input.options === undefined) return { kind: 'unknown' } + return { kind: 'value', value: input.options.join(',') } +} +``` + +```ts ignore-check +const value = a?.b?.c?.d ?? fallback // four layers of fallback hide whether d should exist +``` + +The wrong form keeps the contract vague and swallows the error, so the next caller adds another fallback on top. + +## One value states one fact + +An unknown value, an empty collection or string, and a genuinely absent field are three different facts and take three representations. One `undefined` or `null` never stands for several of them, and a consumer that must act differently on each discriminates explicitly instead of collapsing them at the boundary. + +The judged condition is whether a single "no value" carries more than one semantic: not yet queried, queried and empty, and cannot be determined. A discriminated union or an explicit enum value is the ordinary implementation, documented where the type is defined in [core.md](../subsystems/core.md#repo-wide-type-patterns). + +```ts +type ValueState = { kind: 'unknown' } | { kind: 'empty' } | { kind: 'value'; value: T } +``` + +```ts ignore-check +return null // means "not queried", "queried, nothing there", and "cannot tell", all at once +``` + +The wrong form leaves the caller unable to distinguish the cases, so UI states, cache policy, and retry decisions all go wrong. + +## A new active/current singleton is refused + +No new global `active` or `current` singleton is introduced, and existing ones are migrated out. A globally unique selection is a design statement this codebase does not make: several views of one piece of data each hold their own reference or key. + +The judged condition is whether the change introduces a globally unique selected-state field. The replacement shape is one piece of data with several views over it, each view carrying its own reference. There is no default exception; where the concept genuinely exists once per service, a service or instance field expressed through `inject` carries it, never a module-level field. + +```ts ignore-check +// Each view holds the key it renders; nothing global decides what is "current". +function ConversationView({ sessionKey }: { sessionKey: SessionKey }) { /* … */ } +``` + +```ts ignore-check +let activeTabId: TabId | undefined // module-level; every component reads the same one +``` + +The wrong form makes independent instances interfere with each other, and no reviewer can audit which view owns the focus. + +## Shared state has one owner + +Shared state hangs on a service or an instance, so its owner is a named object with a lifecycle. A module-level cache, a singleton, a global `WeakMap`, or a `Symbol.for` / `globalThis` / `window.__X__` bypass hides that owner and outlives every teardown. + +The judged condition: name the service or instance that owns the value; if no owner can be named, the owner is decided before the implementation. The boundary is that a field on a service or instance may be read by any number of injected consumers, and passing that value across domains goes through the existing interface rather than a global namespace. There is no default exception; a genuine need for a global lookup key goes to the domain owner together with its reclamation policy. + +```ts +// The shared value hangs on the session service instance; consumers inject it. +interface Sessions { readonly current: string } +declare const ctx: { get(name: 'sessions'): Sessions } + +const sessions = ctx.get('sessions') // one owner, lifetime tied to the service +``` + +```ts ignore-check +globalThis.__dshCache = new Map() // shared across instances, reclaimed never +``` + +The wrong form lets instances overwrite each other, makes reclamation timing unknowable, and leaks state between tests. + +## A business capability owns itself + +A business capability lives in its own package and attaches through a registration surface — a registry, `slots.inject`, a waterfall, or an event subscription. A runtime, controller, manager, or gateway carries generic orchestration: it may hold the registry structure and the disposal lifecycle, while the entries are written by the packages that own them, and it never hardcodes a central table of business rows. + +The judged condition is whether the capability declares and registers itself, with the center layer only forwarding and managing lifetime rather than making business decisions. The boundary is that the center layer holds the registry structure and the disposal lifecycle while the entries come from the owning packages. One exception: pure infrastructure — logging, transport, storage — does not attach through a business registration surface. A new branch in a center layer must explain why it cannot live in the owning package. + +```ts +// The business package registers its own slot and handlers in its apply. +interface Slots { + inject(name: string, register: () => void): void + register(name: string): void +} +interface Context { readonly slots: Slots } + +export function apply(ctx: Context): void { + ctx.slots.inject('conversation.footer', () => ctx.slots.register('conversation.footer')) +} +``` + +```ts ignore-check +if (pluginName === 'ui-attachment') { /* business branch inside the manager */ } +``` + +The wrong form makes every new capability edit the center layer, which becomes the change bottleneck and the conflict hotspot. + +## Changes carry performance evidence + +A change that can move the cost of a hot path measures the code it touched before and after and reports the numbers with their conditions. The three metrics are runtime, peak memory, and steady-state memory, measured in one environment on one input; the granularity is the changed path, not a whole-machine score. Reporting that "nothing got slower overall" is not evidence, and a path that improved elsewhere does not excuse a regression here — an optimised first paint still has to answer for `append`. + +The judged condition: under one environment and one input, any of the three metrics is significantly worse on the changed path. + +The baseline is the same path without the change under measurement — normally the native implementation that carries no measurement hook, not the previous implementation. Comparing old against new with the same hook attached on both sides dilutes the difference until it disappears: both sides pay the same overhead, so the measurement reports "no change" while both are slow. + +Measurement is owned by [benchmarks/](../../benchmarks/AGENTS.md), one directory per measured user path, and its scenarios are the entry point: + +```sh +# Run the owning scenario before and after the change; the numbers go into the PR. +pnpm run test:bench +``` + +Package-local diagnostics stay beside their owner with the `.perf.ts` suffix and do not join `test:bench`. The CI gate is `pnpm run check:ci:bench` (job `node 24 / benchmarks`), which publishes a scaled report — that gate is an alert, not a pass: a regression there is located by re-running the owning scenario and profiling segments locally to find which part slowed and which change introduced it. Retained-memory claims use `pnpm run test:bench:memory 4g|8g`, which reruns its listed files under an unchanged budget in a swapless cgroup. + +Boundaries: edits that touch no runtime path — comments, documentation, test-only code — need no measurement, and production performance code is not benchmark instrumentation to be deleted opportunistically (`performance.now()` and `scheduler.yield()` are part of production decode and encode chunk scheduling). An architectural change that genuinely costs performance states the measured cost and the reason, and the domain owner approves it; "the layering needed it" is not a self-service reason. Record the command, the numbers, the environment, and the input size so the baseline can be rebuilt. + +```text +CI benchmark reported green, so the change is accepted as performance-neutral. +``` + +That form ships a real regression in a path the benchmark does not model. + +## Migrations preserve behavior + +A migration or a package split keeps externally observable behavior equal: error codes, diagnostics, data-flow order, and UI timing are compared item by item. Moving code is preferred over deleting and rewriting it; a rewrite suits code whose semantics are changing anyway, and then the change lists its differences. A package or export-path change is a carrier change rather than a behavior change, and a behavior change is its own change. + +The judged condition is the item-by-item comparison of observable behavior before and after. Where full equality is impossible, the change states the differing items and how each is verified, and the domain owner approves. A defect found during a migration is fixed as its own change rather than folded into the move. + +```sh +# The move keeps paths and error codes; the reference updates are the whole diff. +git mv packages/old/src/endpoint.ts packages/new/src/endpoint.ts +``` + +```ts ignore-check +// The migration silently upgrades the error handling it was only supposed to move. +throw new EndpointError('not found') // was: return { ok: false, code: 'not_found' } +``` + +The wrong form buries behavior changes inside a move, so review cannot separate the relocation from the semantics and regressions cannot be localized. + +## Dispose must reach quiescence, not just request it + +A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. + +The judged condition is whether teardown awaits actual quiescence or only requests it. The boundary is work with no owner to await — a detached, unobserved task — which the teardown is not obliged to track; anything the component started and can still observe must be awaited before it reports completion. + +## Contain callback exceptions in the dispatcher + +Catch and report each listener's exception without rejecting the dispatch promise or starving later listeners. The listener's owner must handle state it mutates under the [error-handling rules](../../.agents/skills/dsh-error-handling/SKILL.md#catch-recover-or-stop); catching alone does not restore state. + +The judged condition is whether a throwing listener can reject the dispatch promise or prevent later listeners from running. The boundary is the caller's own await: an error the dispatcher deliberately propagates to the operation that requested it is not a containment failure. + +```ts +async function dispatch(listeners: ((event: string) => Promise)[], event: string): Promise { + for (const listener of listeners) { + try { await listener(event) } catch (error) { console.warn('listener failed', error) } + } +} +``` + +## Never hand untrusted output the ambient environment or predictable paths + +Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. + +The judged condition is whether spawned processes inherit the ambient environment or write somewhere another process can predict and replace. The boundary is trusted, in-process code with no external input, which the rule does not govern. + +## Unlink link-shaped paths + +A path that may be a symlink or Windows junction is removed with `lstatSync().isSymbolicLink()` then `unlinkSync`: unlink deletes only the link and refuses a real directory, so it never follows the link into its target. Windows `rmSync(link)` throws `ERR_FS_EISDIR` on a junction; recursive deletion may descend through one into its target. Reserve recursive `rmSync` for known real directories. + +The judged condition is whether the removal primitive can follow a link into its target. The boundary is a path the code created itself in a directory it owns, where a plain recursive removal is already known to be safe. diff --git a/docs/defensive-patterns/general.zh.md b/docs/defensive-patterns/general.zh.md new file mode 100644 index 0000000000..3aa2b55038 --- /dev/null +++ b/docs/defensive-patterns/general.zh.md @@ -0,0 +1,252 @@ +# 防御性模式 + +[English](general.md) | 中文 + +来之不易的缺陷类别规则:下面每条模式都是本项目实际发布或差点发布的一类缺陷,以防止其复发的规则形式陈述。在编写生命周期、并发、子进程或清理代码之前请先阅读本文。测试层面的对应规则(真实入口路径、验证实际结果、资源归属)见 [testing.zh.md](../testing.zh.md);这些规则引用的类型定义正家见 [subsystems/](../subsystems/README.zh.md)。 + + + +## 正交结果独立上报 + +一个结果可以同时具有多种性质:进程可能已经超时,却仍以退出码 0 结束,因为它捕获了终止信号。每个独立事实(`timedOut`、`signal`、`exitCode`)都应单独上报;切勿把一个标志的上报嵌套在另一个标志的分支中,否则调用方可能把提前终止的运行误判为正常成功。 + +评审看的是**上报形态**而不是终止机制:每个事实能否独立为真,以及能否不进入另一个条件分支就读到它?边界就是这个上报本身——如何发信号、给多长时间是另一个决定。 + + + +## 公共约定两侧都要遵守 + +当一个实现收到同一结果的多种表示时,应在通过公共 API 返回前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmRuntime.stream()` 只会通过终止型 finish 分片暴露模型请求失败;middleware 缺陷与消费方缺陷仍会以异常形式抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、分片日志记录还是自身组装逻辑。请在类型定义处记录规范化后的约定;通过真实消费方覆盖每种来源形式。 + +判定条件是:消费方能否仅凭公共 API 说出故障的来源。内部 helper 仍可自由抛错:这条要求约束的是消费方跨越的那个边界,而不是它背后的每个函数。 + + + +## 异步状态不是同步状态 + +`agent.followup()` 没有逐消息的完成状态或结果;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿把 `agent/status` 或 `whenIdle()` 当作某次 `followup()` 的结果:多条已排队的后续消息、steering(中途引导)和注入工作可能共用同一个 `running` 区间,而取消或资源释放可能丢弃尚未启动的项。真正拥有一次运行的自动化调用方必须显式定义其区间——例如从消息的持久 inbox 回执到整个 agent(智能体)下一次进入 `idle`——并将选取的任何输出描述为整个区间的输出,而不是把因果关系归于该消息。这条守则是双向的:如果等待的转换永远不会发生,等待就会挂起,因此应显式处理「无需等待」的分支。 + +判定条件是:调用方是否观察到一个 API 并未定义的逐单元完成态。区间确实属于调用方时,就由调用方声明区间与输出范围;共享的 `running` 标志永远不是这份声明。 + + + +## 时序不承载正确性 + +正确性来自显式调用点与显式状态,而不是回调落在事件循环的哪个位置。用 `microtask`、`setTimeout` 或 `queueMicrotask` 决定某项工作是否正确,是在与程序其余部分竞争:轻负载下成立,重负载或合批行为一变就静默失效。React 代码同理——effect 绝不提供那种本应由设计显式给出的「渲染之后」次序。 + +判定条件是:正确性是否依赖「那个回调已经跑过」,而不是具名调用点或状态机。两条边界:测试的 timeout 预算是在等失败,不属于靠时序保正确性;纯粹为可见工作 pacing 的延迟也不属本条。开发者一侧没有例外通道,确需时序技巧须经对应领域专家确认。 + +```ts +// The order is written into the code: dispose the old store, then build its successor. +declare let store: { dispose(): void } +declare function createStore(scopeKey: string): { dispose(): void } +declare const scopeKey: string + +store.dispose() +store = createStore(scopeKey) +``` + +```ts ignore-check +queueMicrotask(() => { if (!ready) refresh() }) // assumes the microtask runs before the next frame +setTimeout(() => setMounted(true), 0) // waits for a render that was never sequenced +``` + +反例的失效是间歇性的:偶发丢刷新,或状态永远没同步,只在压力大时复现。 + + + +## 可选输入要写明两种行为 + +可选参数要写明被调用方在**传入**与**不传入**两种情况下各自的行为,调用方从契约里读到两种行为,而不是靠防御性运算符去猜其中一种。堆积 `?.` 就是这个信号:每一个都在替代「这个字段到底应不应该存在」的判断。 + +判定条件是:`?.` 是否被用来替代「该字段存在」的判定,而不是在处理一个已知值上真正可选的一跳。这条要求约束接口契约:两个分支都要写明返回值与副作用,并由调用方从契约处读到。没有例外通道——无法说清两种行为的调用点属于评审打回项,不是风格偏好。 + +```ts +// Both behaviors are in the contract: no options object means "unknown", not "skip". +interface ResolveInput { options?: readonly string[] } +type Resolution = { kind: 'unknown' } | { kind: 'value'; value: string } + +function resolve(input: ResolveInput): Resolution { + if (input.options === undefined) return { kind: 'unknown' } + return { kind: 'value', value: input.options.join(',') } +} +``` + +```ts ignore-check +const value = a?.b?.c?.d ?? fallback // four layers of fallback hide whether d should exist +``` + +反例让契约持续含糊、错误被静默吞掉,于是下一个调用方又叠一层兜底。 + + + +## 一个值只表达一个事实 + +未知值、空集合或空字符串、以及字段真的不存在,是三件不同的事实,用三种不同的表示。一个 `undefined` 或 `null` 绝不代表其中多个;需要在三者上分别处理的消费方显式判别,而不是在边界处把它们压平。 + +判定条件是:同一个「无值」是否承载了多种语义——**未查询**、**查到为空**、**无法判定**。通常的实现是可辨识联合或显式枚举值,并在类型定义处记录(见 [core.zh.md](../subsystems/core.zh.md#repo-wide-type-patterns))。 + +```ts +type ValueState = { kind: 'unknown' } | { kind: 'empty' } | { kind: 'value'; value: T } +``` + +```ts ignore-check +return null // means "not queried", "queried, nothing there", and "cannot tell", all at once +``` + +反例让调用方无法区分这些情况,UI 状态、缓存策略与重试判断都会跟着做错。 + + + +## 新增 active/current 单例一律禁止 + +不得新增全局的 `active` 或 `current` 单例,存量逐步迁出。全局唯一选中态是本仓库不做出的设计声明:同一份数据的多个视图各自持有自己的引用或键。 + +判定条件是:该改动是否引入了全局唯一的选中态字段。替代形态是**一份数据、多份视图**,每个视图带自己的引用。没有默认例外;确实每个服务只有一份的概念由服务或实例的字段承载并经 `inject` 取得,绝不落成模块级字段。 + +```ts ignore-check +// Each view holds the key it renders; nothing global decides what is "current". +function ConversationView({ sessionKey }: { sessionKey: SessionKey }) { /* … */ } +``` + +```ts ignore-check +let activeTabId: TabId | undefined // module-level; every component reads the same one +``` + +反例让彼此独立的实例互相串台,也没有人能审计焦点与状态归谁。 + + + +## 共享状态只有一个所有者 + +共享状态挂在服务或实例上,因此它的所有者是一个有生命周期的具名对象。模块级缓存、单例、全局 `WeakMap`,以及 `Symbol.for` / `globalThis` / `window.__X__` 旁路都会把这个所有者藏起来,并活得比任何一次清理都久。 + +判定条件:说出拥有该值的服务或实例;说不出所有者时,先定所有者再实现。边界是:服务或实例上的字段可以被任意多个注入方读取,跨域传递走既有接口而不是全局命名空间。没有默认例外;确需全局检索键时须经对应领域专家确认,并一并给出回收策略。 + +```ts +// The shared value hangs on the session service instance; consumers inject it. +interface Sessions { readonly current: string } +declare const ctx: { get(name: 'sessions'): Sessions } + +const sessions = ctx.get('sessions') // one owner, lifetime tied to the service +``` + +```ts ignore-check +globalThis.__dshCache = new Map() // shared across instances, reclaimed never +``` + +反例让实例之间互相覆盖、回收时机不可控,并让状态在测试之间泄漏。 + + + +## 业务能力自己拥有自己 + +业务能力住在自己的包里,经注册面接入——registry、`slots.inject`、waterfall 或事件订阅。runtime、controller、manager、gateway 承载通用编排:它可以持有注册表结构与 disposal 生命周期,但条目由拥有它们的包写入,且绝不硬编码一张业务中心表。 + +判定条件是:该能力是否由业务包自己声明并注册,中心层只做转发与生命周期管理、不做业务判定。边界是:中心层持有注册表结构与 disposal 生命周期,条目来自拥有它们的包。一条例外:纯基础设施能力——日志、传输、存储——不经业务注册面接入。中心层新增分支必须说明它为何无法下沉到业务包。 + +```ts +// The business package registers its own slot and handlers in its apply. +interface Slots { + inject(name: string, register: () => void): void + register(name: string): void +} +interface Context { readonly slots: Slots } + +export function apply(ctx: Context): void { + ctx.slots.inject('conversation.footer', () => ctx.slots.register('conversation.footer')) +} +``` + +```ts ignore-check +if (pluginName === 'ui-attachment') { /* business branch inside the manager */ } +``` + +反例让每个新能力都要改中心层,中心层随即成为变更瓶颈与冲突热点。 + + + +## 改动要附带性能证据 + +可能改变热点路径成本的改动,要测量它所触及的代码在改动前后的表现,并把数字与测量条件一并给出。三项指标是运行耗时、内存峰值与稳态内存,在同一环境、同一输入下测量;粒度是**被改动的那条路径**,不是整机跑分。只报「整体没变慢」不算证据,别处变快也不能抵消这里的倒退——优化了首屏,仍然要回答 `append` 是否倒退。 + +判定条件:同一环境、同一输入下,改动后那条路径的三项指标中任一项显著变差。 + +基线是被测路径**不带本次改动**的形态——通常是不挂测量 hook 的原生实现,而不是旧实现。新旧两侧挂着同一个 hook、共同开销把真实差值稀释掉,测量就会报告「没变化」,而实际上两边一起慢。 + +测量的正家是 [benchmarks/](../../benchmarks/AGENTS.md),按被测用户路径分目录,它的场景就是入口: + +```sh +# Run the owning scenario before and after the change; the numbers go into the PR. +pnpm run test:bench +``` + +包内诊断留在包旁边、用 `.perf.ts` 后缀,不进 `test:bench`。CI 门禁是 `pnpm run check:ci:bench`(作业 `node 24 / benchmarks`),产出 scaled report——该门禁只是**报警**,不是通过依据:出现退化时重跑对应场景并本地分段 profile,定位是哪一段变慢、哪次变更引入。内存留存类结论用 `pnpm run test:bench:memory 4g|8g`,它会在无 swap 的 cgroup 里以不变预算重跑其列出的文件。 + +边界:不触碰运行路径的改动——注释、文档、仅测试代码——不需要测量;生产性能代码不是可以顺手删掉的 benchmark instrumentation(`performance.now()`、`scheduler.yield()` 属于生产 decode/encode 分片调度的一部分)。架构调整确实要付出性能代价时,写明实测代价与理由,并由对应领域专家确认;「分层需要」不是自助理由。命令、数字、环境与输入规模都要落账,以便返工时重建同一基线。 + +```text +CI benchmark reported green, so the change is accepted as performance-neutral. +``` + +这种写法会把 benchmark 未建模路径上的真实退化直接放行。 + + + +## 迁移保持行为 + +迁移或拆包保持对外可观测行为全等:错误码、诊断信息、数据流顺序与 UI 时机逐项比对。优先搬运代码而不是删了重写;重写适用于本来就要改语义的代码,且此时要在改动里列出差异。包或导出路径变化属于载体变化而非行为变化,行为变化则是独立的一刀。 + +判定条件是改动前后可观测行为的逐项比对。无法做到完全全等时,改动要写明差异项与各自的验收方式,并由对应领域专家确认。迁移过程中发现的缺陷按其自身另开一刀,不并入这次搬运。 + +```sh +# The move keeps paths and error codes; the reference updates are the whole diff. +git mv packages/old/src/endpoint.ts packages/new/src/endpoint.ts +``` + +```ts ignore-check +// The migration silently upgrades the error handling it was only supposed to move. +throw new EndpointError('not found') // was: return { ok: false, code: 'not_found' } +``` + +反例把行为变化埋进搬运里,评审无法把位置移动与语义变化分开,回归也就无法定位。 + + + +## dispose 必须达到完全停稳,而不仅仅是请求停止 + +如果清理流程只发出终止或中止信号便返回,而不等待工作真正停止,就会留下孤儿进程。清理逻辑应采用异步流程,并等待子进程退出(发出终止信号后等待 `done`);还应在终止进程前关闭监听器注册表和通知注册表,使迟到的完成事件保持静默。 + +判定条件是清理是否等待到真正停稳,而不只是发出请求。边界是没有可等待归属的工作——无人观察的游离任务——清理逻辑没有义务追踪它;但组件自己启动、且仍能观察到的工作,必须在它报告完成之前等待完毕。 + + + +## 在分发器中隔离回调异常 + +逐个捕获并报告监听器的异常,不让分发 promise 被 reject,也不让后续监听器失去执行机会。监听器的所有者必须按[错误处理规范](../../.agents/skills/dsh-error-handling/SKILL.md#catch-recover-or-stop)处置其修改的状态;仅捕获异常不会恢复状态。 + +判定条件是:抛异常的监听器是否能让分发 promise 被 reject,或让其后的监听器不再运行。边界是调用方自己的 await——分发器有意抛给发起该操作的那条路径的错误,不属于隔离失败。 + +```ts +async function dispatch(listeners: ((event: string) => Promise)[], event: string): Promise { + for (const listener of listeners) { + try { await listener(event) } catch (error) { console.warn('listener failed', error) } + } +} +``` + + + +## 绝不将环境变量或可预测路径暴露给不可信输出 + +启动的命令应使用经过清理的环境变量,移除名称匹配 `*KEY*`、`*SECRET*`、`*TOKEN*` 或 `*PASSWORD*` 的项,防止 harness 凭证通过命令输出、`env` 或 spill 文件泄漏。临时文件和 spill 文件应放在权限为 0700 的私有目录中,使用随机文件名,并以独占且仅所有者可访问的方式打开(`'wx'`、`0o600`);可预测且全局可读的路径会引发符号链接竞态和信息泄露。 + +判定条件是:启动的进程是否继承环境变量,或是否写入别人可以预测并替换的位置。边界是不接受外部输入的受信进程内代码,本条不约束它。 + + + +## 用 unlink 删除链接形态的路径 + +可能是符号链接或 Windows junction 的路径,应先用 `lstatSync().isSymbolicLink()` 判断,再用 `unlinkSync` 删除:unlink 只删除链接本身并拒绝真实目录,因此绝不会跟随链接进入其目标。Windows 上对 junction 调用 `rmSync(link)` 会抛 `ERR_FS_EISDIR`;递归删除可能穿过 junction 进入其目标。真实目录才使用带 `recursive` 的 `rmSync`。 + +判定条件是:所用的删除原语是否可能跟随链接进入其目标。边界是代码在自己拥有的目录里自行创建的路径,那里已知普通递归删除是安全的。 diff --git a/docs/defensive-patterns/package-surface.i18n.yaml b/docs/defensive-patterns/package-surface.i18n.yaml new file mode 100644 index 0000000000..6d1bd9ae33 --- /dev/null +++ b/docs/defensive-patterns/package-surface.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for package-surface.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/defensive-patterns/package-surface.md +/package-surface-rules: + en: 28d4c2d7da24269f + zh: d723b807815401d7 +/package-surface-rules/a-plugin-package-exports-what-the-loader-loads: + en: 44a06f50e751f06b + zh: 339dea7be403fd0b +/package-surface-rules/two-package-shapes-and-both-are-dynamic-plugin-packages: + en: bf79c6acf35c90a5 + zh: 9f2ce6c7f6493b17 +/package-surface-rules/package-json-exports-uses-the-standard-key-set: + en: 3604bf435be1c430 + zh: 169621c8a0c2dc4c +/package-surface-rules/dependency-declarations-and-version-consistency-are-enforced-by-the-dependency-scan: + en: e68bfcde70f845b4 + zh: 73e5c4fb067c4fa1 +/package-surface-rules/internal-dependency-syntax-follows-the-package-family: + en: d6bb855545b7b05b + zh: 6c489ed5a2ed0ae5 +/package-surface-rules/a-new-resolve-hook-is-refused-outright: + en: b420ae3e228432d5 + zh: 0c49c4a9c3a5262c diff --git a/docs/defensive-patterns/package-surface.md b/docs/defensive-patterns/package-surface.md new file mode 100644 index 0000000000..9a4b772b1e --- /dev/null +++ b/docs/defensive-patterns/package-surface.md @@ -0,0 +1,224 @@ +# Package surface rules + +English | [中文](package-surface.zh.md) + +Rules for what a package exports, how it is built, and how it declares dependencies. [development.md](../development.md#typescript-project-layout) describes the layout itself; [packages/AGENTS.md](../../packages/AGENTS.md) carries the standing orders. + +## A plugin package exports what the Loader loads + +A plugin package's entry point takes one of two forms, and they are not mixed. A **function plugin** named-exports `name`, `inject`, `Config`, and `apply`: `name`, `inject`, and `apply` are lower-case, while `Config` is upper-case and appears both as an `interface` and as its Schemastery schema value. A **service plugin** default-exports its service class, which is how the Loader loads it; a default export beside the four makes the Loader discard the function plugin's namespace. Beyond those, a Host entry may export the Service Definition classes and error types that other packages extend or throw — the capability seam consumes exactly those — and never a test-convenience value. The `/client` entry of a UI plugin package is stricter, and [packages/client/AGENTS.md](../../packages/client/AGENTS.md) owns that discipline: a same-package test reaches internals through a relative `../src/...` path or the `./src/*` subpath, so a test-only need never becomes a permanent export. The rule stops a test convenience from freezing into published API. + +- The Loader four and its exact form are defined in [cordis-primer.md](../cordis-primer.md#loader-configuration). +- Judged condition: a function plugin's entry (`src/index.ts`) exports an entity beyond the four; a default export is added beside the four, or the two forms are mixed; a `/client` entry exports an implementation value or lets another feature plugin import one; a fixture reaches another package by relative path. +- Boundary: only Loader plugin entry points are constrained. Internal modules, helper functions, and hooks keep exporting values; a Host entry's Service Definition classes and error types are expected exports; and a test fixture stays inside its own package — a relative path into another package's `src` bypasses the `exports` map and the package-boundary checks. +- Exception: none by default; an exception that is genuinely needed requires the domain owner's confirmation. + +The compliant entry — the four exports, with the same-package test path recorded as a comment rather than as a fifth export. + +```ts +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' + +// The plugin entry: the Loader four, and no fifth value +export const name = 'web-app' +export const inject = ['webServer'] +export interface Config { /* … */ } +export const Config = z.object({ /* … */ }) +export function apply(ctx: Context, config: Config): void { /* … */ } + +// A same-package test imports '../src/internal/header.ts' instead of this entry +``` + +Three ways the entry gains an export it must not have; all three are visible in the diff. + +```ts ignore-check +// The entry widened so a test could reach one helper +export { isHeaderLine } from './internal/header.ts' + +// Both plugin forms on one entry: the Loader discards the four +export default class WebApp {} + +// A fixture reaching into another package by relative path +import { isSurfaceEligibleType } from '../../core/session/src/surface.ts' +``` + +## Two package shapes, and both are dynamic plugin packages + +A new package is either a Host package or a Host + Client package, and both are dynamic plugin packages; no third form is available and there is no exception. The static client libraries the shell seeds through `PLATFORM_MODULES` in [packages/client/web/src/platform.ts](../../packages/client/web/src/platform.ts) — `client-store`, `ui-slots`, `ui-primitives`, `ui-dockkit` — and the zero-dependency libraries under [packages/util/](../../packages/util/) predate this rule and are closed: they keep working as they are, and a new package never joins them. Only a genuine both-face package splits its compiler faces, build output goes to `lib`, and the `include`, `external`, and roster lists are derived by scanning the workspace rather than hand-written. A hand-written list or an invented compiler face makes build output and the manifest disagree, and every later package would have to maintain its own list. + +- Judged condition: a new package built as anything but a dynamic plugin package (static-link bundling, a hand-written all-external list); a third `tsconfig` variant; a hand-written `include`, `external`, or roster list. +- Recompute the shapes instead of trusting a number here: list the `tsconfig.json` files under `packages/*/*`, then the packages that also carry `tsconfig.client.json` and `tsconfig.host.json`, then those declaring `dsh.client`. +- Boundary: a both-face package may appear in `include` or `exclude`. +- Exception: none; there is no approval path. A change to a package's shape or compiler faces starts with the owning documents and needs the domain owner's confirmation. +- Owners: [development.md](../development.md#typescript-project-layout) for the compiler faces, [packages/README.md](../../packages/README.md) for the package groups, and [adding-a-package.md](../cookbook/adding-a-package.md) for the steps. + +A package in one of the two shapes, with the standard manifest fields and no hand-written external list. + +```json +{ + "name": "@deepseek-ai/dsh-example", + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" } + } +} +``` + +A hand-written `external` list — and a `tsconfig.foo.json` invented beside it for one package — leaves the scans nothing to derive, so build output and manifest stop agreeing. + +```json +{ "name": "@deepseek-ai/dsh-example", "external": ["@deepseek-ai/*"] } +``` + +## `package.json` `exports` uses the standard key set + +A `package.json` `exports` map uses only the standard key set, in which every key already has one meaning across the repository, and a package never opens a key for its own local purpose. A key that only a handful of packages use without a documenting owner is not a precedent: formalizing a key, including one of the undocumented keys below, means changing the owning document and the check in the same change. The small shared key set is what lets a consumer infer a key's purpose from its name and lets a scan judge the export map at all. + +- Standard keys, each with one meaning repo-wide, across `packages/*/*/package.json` and `apps/*/package.json`: `.` (the package entry), `./package.json` (toolchain access, not a semantic export), `./src/*` (source passthrough for tests and tools, whose documented status is still open), `./client` (the browser entry, paired with the `dsh.client` declaration), `./types` (the type entry), `./typert` (the typert type runtime), `./remote` (the `@Remote` call surface), `./locale/*.json` (locale resources), `./cordis.patch.yml` (the bundle patch layer declared by `dsh.bundle`). Counts are not listed here because they rot on the next package; the recompute command below prints them. +- Keys outside that set (`./brand` in 5 packages, `./worker` in 4, `./startup`, `./runner`, and `./protocol` in 2 each, plus many one-off keys) have no documented meaning; they are pending review, not a pattern to copy. +- Judged condition: does a change add an export key outside the standard set? +- Exception: none by default; a new key changes the owning document and the check first, with the domain owner's confirmation. +- Recompute the distribution: + +```sh +python3 -c "import json,glob,collections; c=collections.Counter(); [c.update(json.load(open(p)).get('exports',{}).keys()) for p in glob.glob('packages/*/*/package.json')+glob.glob('apps/*/package.json') if isinstance(json.load(open(p)).get('exports'),dict)]; print(c.most_common())" +``` + +The compliant map — three standard keys used with their repo-wide meanings, in a package that also declares its client entry. + +```json +{ + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + } +} +``` + +Keys opened for one package's local purposes — here `./brand` and `./worker`, whose documented status is pending — turn the export map into a per-package convention that neither a consumer nor a scan can read. + +```json +{ + "exports": { + ".": "./lib/index.js", + "./brand": "./lib/brand.js", + "./worker": "./lib/worker.js" + } +} +``` + +## Dependency declarations and version consistency are enforced by the dependency scan + +Dependency declarations and version consistency are guaranteed mechanically by the dependency scan; this page repeats neither the `devDependencies` and `peerDependencies` field rules nor a package admission list of its own. When the scan fails, reproduce it locally and fix the manifest it names, because the scan and its policy file own the rules. Editing the scan or relaxing its policy so that a change can land is not a normal edit and needs the domain owner's confirmation. + +- Entry point: [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts), run as `pnpm run verify-package-dependencies`, by `scripts/run-gates.ts` as the `package-dependencies` gate, and directly by `.github/workflows/release.yml`. +- Policy owner: [package-dependency-policy.ts](../../scripts/package-dependency-policy.ts) exports `PACKAGE_DEPENDENCY_POLICY`; the declaration sections, version forms, and exemptions are defined there, with package-level context in [packages/README.md](../../packages/README.md). +- Boundary: this page lists no packages, field rules, or thresholds; it points at the owning file instead. +- Find the wiring: `grep -rn "verify-package-dependencies" package.json .github/workflows scripts/run-gates.ts`. +- Exception: a change to the scan or to the policy requires the domain owner's confirmation and the reason recorded on the change. + +The repeatable procedure: reproduce, read the diagnostic, and fix the manifest it names. + +```sh +# 1. Reproduce what CI reports +pnpm run verify-package-dependencies + +# 2. The diagnostic names the manifest, the section, the dependency, and the expected range +# packages/example/package.json: dependencies.@deepseek-ai/dsh-base must use workspace:*, found workspace:~ + +# 3. The fix goes into the manifest, never into the policy file or the scan +# "dependencies": { "@deepseek-ai/dsh-base": "workspace:*" } + +# 4. Re-run the scan until every package matches the published policy +pnpm run verify-package-dependencies +``` + +Two ways to lose the mechanical guarantee: change the check instead of the manifest, or restate the rule here so that it drifts from its owner. + +```text +Relaxing PACKAGE_DEPENDENCY_POLICY, or dropping the scanned section that fails, to go green +Restating a required-devDependency package list or the field rules on this page instead of linking the policy file +``` + +## Internal dependency syntax follows the package family + +Internal dependency syntax follows the package family: `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` use `workspace:*`, every other internal package uses `workspace:~`, and `workspace:^` has retired. The same dependency name is written the same way in every workspace, and a mismatch is a hard scan failure rather than a warning a reviewer can waive. The uniform syntax keeps one resolution path and prevents duplicate instances of a dependency and the resolution differences that follow them. + +- Judged by `workspaceRange()` in [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts): the `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` families get `workspace:*`, every other internal package gets `workspace:~`; the scan walks the managed manifests and compares every declaration of the same dependency name. +- Boundary: a same-namespace package that is not a workspace member declares an ordinary third-party range and is outside the family rule — `@deepseek-ai/libreoffice-kit` is declared `^0.1.5` in `apps/desktop-host/package.json` (dependencies), `packages/bundle/web-app/package.json` (devDependencies), `packages/document/office-to-pdf/package.json` (dependencies), and `packages/skill/skill-office/package.json` (dependencies). +- Distribution is not restated here; the recompute command below prints the current counts of `workspace:*`, `workspace:~`, `workspace:^`, and any exact-range declaration outside the family rule. +- Exception: the scan decides mechanically; a genuine exception means changing the scan and the policy file with the domain owner's confirmation and the reason on the change. +- Recompute: + +```sh +python3 -c "import json,glob,collections; c=collections.Counter(); [c.update([r]) for p in glob.glob('apps/*/package.json')+glob.glob('packages/*/*/package.json')+glob.glob('vendor/*/package.json') for s in ('dependencies','devDependencies','optionalDependencies','peerDependencies') for n,r in (json.load(open(p)).get(s) or {}).items() if n.startswith('@deepseek-ai/')]; print(c.most_common())" +``` + +Each family in its form, with the non-member exception left as an ordinary version range. + +```json +{ + "dependencies": { + "@deepseek-ai/dsh-base": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/libreoffice-kit": "^0.1.5" + }, + "devDependencies": { + "@deepseek-ai/dsh-typert-registry": "workspace:*" + } +} +``` + +A `dsh-*` package on `workspace:~` and a workspace member pinned to an external range — each is a hard scan failure, and the retired rule of pinning exact versions is not available as an alternative. + +```json +{ + "dependencies": { + "@deepseek-ai/dsh-base": "workspace:~", + "@deepseek-ai/cordis": "^4.0.0" + } +} +``` + +## A new resolve hook is refused outright + +A new `resolve` hook is refused by default — a definite prohibition, not a preference to avoid one. A genuine need has two preconditions together: re-auditing the resolve strategy as a whole rather than patching the specifier that motivated the change, and the domain owner's confirmation before anything lands. An author's own judgment that this case is special is not a confirmation. + +- Judged condition: does the change add a `resolve` hook, including a wrapper around an existing hook that rewrites the same specifiers? +- Boundary: this rule decides whether a hook may be added at all; the separation of resolution, hot reload, and plugin management responsibilities is governed separately and does not permit a new hook. +- The one resolution path is the tsx ESM hook registered by the `dsh` source launch, whose rationale is in the [source-launch Agent Note](../../.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md). +- Exception: only the two preconditions above, both recorded on the change. + +The compliant path when new resolution behavior is genuinely needed, in order. + +```text +A new resolve hook is allowed only after both preconditions hold: + +1. Re-audit the resolve strategy as a whole — resolution, hot reload, and plugin + management responsibilities together, not only the specifier at hand. +2. Record the domain owner's confirmation on the change. +3. Only then add the hook, linking both records from the change. + +Until both hold, the answer is no; "this case is special" is the author's judgment, +not a confirmation. +``` + +A hook added so that one specifier lands where this package expects it, or the equivalent wrapper around the existing hook — the resolution path gains a branch, and what the resolver returns no longer matches `node_modules`. + +```ts ignore-check +// Added so one specifier resolves to the local source copy +export async function resolve(specifier: string, parentURL: string, importAttributes: object) { + if (specifier === '@deepseek-ai/dsh-example') return { url: localSourceUrl, shortCircuit: true } + return defaultResolve(specifier, parentURL, importAttributes) +} + +// The equivalent shape: wrapping the hook that already exists +const previous = hooks.resolve +hooks.resolve = (specifier, context, next) => + pinned.has(specifier) ? pinned.get(specifier) : previous(specifier, context, next) +``` diff --git a/docs/defensive-patterns/package-surface.zh.md b/docs/defensive-patterns/package-surface.zh.md new file mode 100644 index 0000000000..140f103264 --- /dev/null +++ b/docs/defensive-patterns/package-surface.zh.md @@ -0,0 +1,224 @@ +# 包面规则 + +[English](package-surface.md) | 中文 + +关于包导出什么、怎么构建、如何声明依赖的规则。布局本身见 [development.zh.md](../development.zh.md#typescript-project-layout);常设条款见 [packages/AGENTS.md](../../packages/AGENTS.md)。 + +## 插件包导出 Loader 所需的东西 + +插件包的入口取两种形态之一,两者不得混用。**函数插件**具名导出 `name`、`inject`、`Config`、`apply`:`name`、`inject`、`apply` 全小写,`Config` 首字母大写,且同时以 `interface` 与 Schemastery schema 值两种形态出现。**服务插件**默认导出它的服务类,Loader 就是这么加载它的;在四件套旁边再加一个默认导出,会让 Loader 丢弃函数插件的命名空间。除这两者之外,Host 入口可以导出供其他包 `extends` 或 `throw` 的 Service Definition 类与错误类型——能力接缝消费的正是这些——但绝不导出测试便利值。UI 插件包的 `/client` 入口更严,[packages/client/AGENTS.md](../../packages/client/AGENTS.md) 是那条纪律的正家:同包测试用相对 `../src/...` 路径或 `./src/*` 子路径触达内部,测试专用的需要因此永远不会变成常驻导出。 + +- Loader 四件套及其准确形态见 [cordis-primer.zh.md](../cordis-primer.zh.md#loader-configuration)。 +- 判定条件:函数插件的入口(`src/index.ts`)导出了四件套以外的实体;在四件套旁边加了默认导出,或两种形态被混用;`/client` 入口导出了实现值、或让另一个功能插件导入了实现值;fixture 用相对路径引用其他包。 +- 边界:只约束 Loader 插件入口。内部模块、工具函数与 hooks 照常导出值;Host 入口的 Service Definition 类与错误类型是应有的导出;测试 fixture 也留在本包内——用相对路径伸进别的包的 `src` 会绕过 `exports` 映射与包边界检查。 +- 例外:无默认例外;确需破例须经对应领域专家确认。 + +合规的入口——四件套,同包测试路径以注释记录,而不是加出第五个导出。 + +```ts +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' + +// The plugin entry: the Loader four, and no fifth value +export const name = 'web-app' +export const inject = ['webServer'] +export interface Config { /* … */ } +export const Config = z.object({ /* … */ }) +export function apply(ctx: Context, config: Config): void { /* … */ } + +// A same-package test imports '../src/internal/header.ts' instead of this entry +``` + +三种写法都会让入口多出不该有的导出;三种都能在 diff 里看到。 + +```ts ignore-check +// The entry widened so a test could reach one helper +export { isHeaderLine } from './internal/header.ts' + +// Both plugin forms on one entry: the Loader discards the four +export default class WebApp {} + +// A fixture reaching into another package by relative path +import { isSurfaceEligibleType } from '../../core/session/src/surface.ts' +``` + +## 包只有两类,且两类都是动态插件包 + +新增包要么是 Host 包,要么是 Host + Client 包,两类都是动态插件包;没有第三种形态可选,也没有例外。shell 经 [packages/client/web/src/platform.ts](../../packages/client/web/src/platform.ts) 的 `PLATFORM_MODULES` 播种的静态客户端库——`client-store`、`ui-slots`、`ui-primitives`、`ui-dockkit`——以及 [packages/util/](../../packages/util/) 下的零依赖库先于本条规则存在,且**已关闭**:它们照原样继续工作,新增包永不加入其中。只有真正的双面包才拆编译面,生成物进 `lib`,`include`、`external` 与 roster 清单由扫描 workspace 得出,不手写。手写清单或新造编译面会让构建产物与清单不再一致,此后每个新增包都得各自维护清单。 + +- 判定条件:新增包未按动态插件包形态构建(static link 打包、手写的全 external 清单);出现第三种 `tsconfig` 变体;手写 `include`、`external` 或 roster 清单。 +- 形态不要照抄数字,就地复算:先列 `packages/*/*` 下的 `tsconfig.json`,再列同时带 `tsconfig.client.json` 与 `tsconfig.host.json` 的包,最后列声明 `dsh.client` 的包。 +- 边界:双面包允许被 `include` 或 `exclude`。 +- 例外:无;没有审批通道。包的形态或编译面变更须先改正家文档,并须经对应领域专家确认。 +- 正家:[development.zh.md](../development.zh.md#typescript-project-layout) 管编译面,[packages/README.zh.md](../../packages/README.zh.md) 管包分组,[adding-a-package.zh.md](../cookbook/adding-a-package.zh.md) 管步骤。 + +属于两类之一的包:使用标准清单字段,没有手写 external 清单。 + +```json +{ + "name": "@deepseek-ai/dsh-example", + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" } + } +} +``` + +手写的 `external` 清单——以及在它旁边为单个包新造的 `tsconfig.foo.json`——让扫描无从推导,构建产物与清单随之不再一致。 + +```json +{ "name": "@deepseek-ai/dsh-example", "external": ["@deepseek-ai/*"] } +``` + +## `package.json` `exports` 使用标准键集合 + +`package.json` 的 `exports` 只使用标准键集合,集合中每个键在全仓已经只有一个含义,包不得为自己的局部用途开键。只有少数几个包在用、又没有文档正家的键不构成先例:把某个键正式化(包括下面那些未记录在案的键)意味着在同一次改动里改正家文档与检查。键集合小且共享,消费者才能从键名推断用途,扫描也才有可能判定导出映射。 + +- 标准键,每个键在全仓只有一个含义,统计口径是 `packages/*/*/package.json` 与 `apps/*/package.json`:`.`(包主入口)、`./package.json`(工具链访问,不是语义导出)、`./src/*`(测试与工具的源码透传,文档状态仍待定)、`./client`(浏览器入口,与 `dsh.client` 声明配对)、`./types`(类型入口)、`./typert`(typert 类型运行时)、`./remote`(`@Remote` 调用面)、`./locale/*.json`(本地化资源)、`./cordis.patch.yml`(`dsh.bundle` 声明的 bundle 补丁层)。这里不列各键的包数,因为下一个包就会让它过期;下面的复算命令会打印当前数字。 +- 集合之外的键(`./brand` 5 个包、`./worker` 4 个、`./startup`、`./runner`、`./protocol` 各 2 个,另有大量一次性键)没有文档含义,属待核项,不是可照抄的先例。 +- 判定条件:改动是否新增了标准集合以外的 export 键? +- 例外:无默认例外;新增键须先改正家文档与检查,并取得对应领域专家确认。 +- 复算分布: + +```sh +python3 -c "import json,glob,collections; c=collections.Counter(); [c.update(json.load(open(p)).get('exports',{}).keys()) for p in glob.glob('packages/*/*/package.json')+glob.glob('apps/*/package.json') if isinstance(json.load(open(p)).get('exports'),dict)]; print(c.most_common())" +``` + +合规的映射——三个标准键各按全仓含义使用,该包同时声明了 client 入口。 + +```json +{ + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + } +} +``` + +为单个包的局部用途开的键——这里是文档地位待核的 `./brand` 与 `./worker`——让导出映射退化为各包自便的约定,消费者与扫描都读不懂。 + +```json +{ + "exports": { + ".": "./lib/index.js", + "./brand": "./lib/brand.js", + "./worker": "./lib/worker.js" + } +} +``` + +## 依赖声明与版本一致性由依赖扫描门禁保证 + +依赖声明与版本一致性由依赖扫描机械保证;本页既不复述 `devDependencies` 与 `peerDependencies` 的字段规则,也不另立包准入清单。扫描失败时,在本地复现并修正它指名的清单,因为规则的正家是扫描与其策略文件。为了让改动落地而修改扫描或放宽策略不属于常规改动,须经对应领域专家确认。 + +- 入口:[verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts),以 `pnpm run verify-package-dependencies` 运行,被 `scripts/run-gates.ts` 作为 `package-dependencies` 门禁调用,`.github/workflows/release.yml` 也直接运行它。 +- 策略正家:[package-dependency-policy.ts](../../scripts/package-dependency-policy.ts) 导出 `PACKAGE_DEPENDENCY_POLICY`;声明面、版本形态与豁免都在该文件定义,包级背景见 [packages/README.zh.md](../../packages/README.zh.md)。 +- 边界:本页不列包清单、字段规则或阈值,只指向正家文件。 +- 查接线:`grep -rn "verify-package-dependencies" package.json .github/workflows scripts/run-gates.ts`。 +- 例外:修改扫描或策略须经对应领域专家确认,并在改动上记录理由。 + +可重复的流程:复现、读诊断、修正它指名的清单。 + +```sh +# 1. Reproduce what CI reports +pnpm run verify-package-dependencies + +# 2. The diagnostic names the manifest, the section, the dependency, and the expected range +# packages/example/package.json: dependencies.@deepseek-ai/dsh-base must use workspace:*, found workspace:~ + +# 3. The fix goes into the manifest, never into the policy file or the scan +# "dependencies": { "@deepseek-ai/dsh-base": "workspace:*" } + +# 4. Re-run the scan until every package matches the published policy +pnpm run verify-package-dependencies +``` + +失去机械保证的两种做法:改检查而不改清单,或者把规则复述到本页、从此与正家漂移。 + +```text +Relaxing PACKAGE_DEPENDENCY_POLICY, or dropping the scanned section that fails, to go green +Restating a required-devDependency package list or the field rules on this page instead of linking the policy file +``` + +## 内部依赖写法按包族保证 + +内部依赖写法按包族决定:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 用 `workspace:*`,其余内部包用 `workspace:~`,`workspace:^` 已退出。同一个依赖名在所有 workspace 内写法一致,不一致是扫描硬失败,不是评审可以放行的警告。写法统一才能保持一条解析路径,避免同一个依赖出现重复实例以及随之而来的解析差异。 + +- 判定在 [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts) 的 `workspaceRange()`:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 包族取 `workspace:*`,其余内部包取 `workspace:~`;扫描遍历受管清单,比对同一依赖名的每一处声明。 +- 边界:同命名空间但不是 workspace 成员的包按普通第三方版本范围声明,不受包族规则约束——`@deepseek-ai/libreoffice-kit` 在 `apps/desktop-host/package.json`(dependencies)、`packages/bundle/web-app/package.json`(devDependencies)、`packages/document/office-to-pdf/package.json`(dependencies)、`packages/skill/skill-office/package.json`(dependencies)中声明为 `^0.1.5`。 +- 分布不在此复述;下面的复算命令会打印 `workspace:*`、`workspace:~`、`workspace:^` 的当前数量,以及族规则之外的精确版本声明。 +- 例外:门禁机械判定;确需例外意味着修改扫描与策略文件,并须经对应领域专家确认、在改动上说明理由。 +- 复算: + +```sh +python3 -c "import json,glob,collections; c=collections.Counter(); [c.update([r]) for p in glob.glob('apps/*/package.json')+glob.glob('packages/*/*/package.json')+glob.glob('vendor/*/package.json') for s in ('dependencies','devDependencies','optionalDependencies','peerDependencies') for n,r in (json.load(open(p)).get(s) or {}).items() if n.startswith('@deepseek-ai/')]; print(c.most_common())" +``` + +每个包族各用其写法,非成员的例外保持普通版本范围。 + +```json +{ + "dependencies": { + "@deepseek-ai/dsh-base": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/libreoffice-kit": "^0.1.5" + }, + "devDependencies": { + "@deepseek-ai/dsh-typert-registry": "workspace:*" + } +} +``` + +`dsh-*` 包写成 `workspace:~`,以及把 workspace 成员钉到外部版本范围——两者都是扫描硬失败,已退出的「一律钉精确版本」旧口径不能作为替代写法。 + +```json +{ + "dependencies": { + "@deepseek-ai/dsh-base": "workspace:~", + "@deepseek-ai/cordis": "^4.0.0" + } +} +``` + +## 新增 resolve 钩子直接禁止 + +新增 `resolve` 钩子默认被拒——这是确定性禁止,不是「尽量不」。确有需要时,两个前置条件必须同时成立:整体重审 resolve 策略,而不是给触发本次改动的那个 specifier 打补丁;以及在任何内容落地之前取得对应领域专家的确认。开发者自行判断「这次特殊」不构成确认。 + +- 判定条件:改动是否新增 `resolve` 钩子,包括包装既有钩子、改写同一批 specifier 的等价写法? +- 边界:本条只管能否新增钩子;解析、热更新与插件管理三者的职责划分另行治理,并不因此允许新增钩子。 +- 唯一的解析路径是 `dsh` 源码启动注册的 tsx ESM 钩子,其理由见[源码启动 Agent Note](../../.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md)。 +- 例外:只有上述两个前置条件,且都要记录在改动上。 + +确有新的解析需求时的合规路径,按顺序。 + +```text +A new resolve hook is allowed only after both preconditions hold: + +1. Re-audit the resolve strategy as a whole — resolution, hot reload, and plugin + management responsibilities together, not only the specifier at hand. +2. Record the domain owner's confirmation on the change. +3. Only then add the hook, linking both records from the change. + +Until both hold, the answer is no; "this case is special" is the author's judgment, +not a confirmation. +``` + +为了让某个 specifier 落到本包期望的位置而加的钩子,或包装既有钩子达到同样效果的等价写法——解析路径多出一个分支,resolver 的返回与 `node_modules` 实况不再对应。 + +```ts ignore-check +// Added so one specifier resolves to the local source copy +export async function resolve(specifier: string, parentURL: string, importAttributes: object) { + if (specifier === '@deepseek-ai/dsh-example') return { url: localSourceUrl, shortCircuit: true } + return defaultResolve(specifier, parentURL, importAttributes) +} + +// The equivalent shape: wrapping the hook that already exists +const previous = hooks.resolve +hooks.resolve = (specifier, context, next) => + pinned.has(specifier) ? pinned.get(specifier) : previous(specifier, context, next) +``` diff --git a/docs/defensive-patterns/remote-and-api.i18n.yaml b/docs/defensive-patterns/remote-and-api.i18n.yaml new file mode 100644 index 0000000000..e36bfb5699 --- /dev/null +++ b/docs/defensive-patterns/remote-and-api.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for remote-and-api.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/defensive-patterns/remote-and-api.md +/remote-and-api-rules: + en: 26fb6e92d16925b0 + zh: 72d13c0481fc61e9 +/remote-and-api-rules/a-new-remote-capability-gets-its-own-service: + en: 5759a7b0f96ea4bc + zh: 62465fbe91a4d2af +/remote-and-api-rules/remote-marks-capability-methods: + en: e5613ec07023429d + zh: 9272c9ae5718060a +/remote-and-api-rules/packages-api-carries-generic-orchestration-only: + en: 12a964b9c04fccf9 + zh: cf804f8044535727 diff --git a/docs/defensive-patterns/remote-and-api.md b/docs/defensive-patterns/remote-and-api.md new file mode 100644 index 0000000000..25281e96fb --- /dev/null +++ b/docs/defensive-patterns/remote-and-api.md @@ -0,0 +1,114 @@ +# Remote and API rules + +English | [中文](remote-and-api.zh.md) + +Rules for the `packages/api` layer and the remote methods it exposes. [api-gateway.md](../api-gateway.md) describes the call model itself. Each section below states the requirement, how a reviewer judges it, what it does not cover, which exception applies and who approves it, and a correct and a wrong example. + +## A new remote capability gets its own service + +A new remote business capability is a new service — its own package or its own object — and `@Remote` marks that service's concrete methods. Growing an existing large service one method at a time leaves one object answering for auth, Session, workspace, and event carriage at once, mixes per-domain activation policy with history pagination, and makes every later change to one capability touch all of them. Split by business boundary, not by caller count: a capability with a single caller today still gets its own service, and an endpoint that changes owners moves to its new owner instead of joining the object it is leaving. + +- Judged by: which object holds the new remote method. A method for a new business line added to a service that already owns another line is the defect; the fix is a service named for the new capability, with `@Remote` on its concrete methods. A migration is judged the same way — the moved method appears on its new owner, as `SubagentRuntime` does in `packages/subagent/subagent`, not on the object it left. +- Not covered: a service whose methods all serve one business boundary stays whole, and a new private helper of that capability belongs on it. Data that needs its own protocol does not become a Remote method on the new service either; session event streams, pagination, incremental reduce, projection, and entity substreams keep their separate registration model ([gateway boundaries](../api-gateway.md#boundaries)). +- Exception: none recorded; a single caller is not a reason to merge. Where the business boundary itself is contested, the domain owner settles it as an architecture question before the change lands. +- Fix: create the capability's own service object or package, put `@Remote` on its concrete methods, and let callers reach it through `ctx.remote.`. + +The subagent capability owns its own service, and every remote method is a concrete method of that service: + +```ts ignore-check +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +interface SubagentList { ids: string[] } +interface PromptRequest { text: string } +interface PromptResult { accepted: boolean } + +class SubagentRuntime extends TypertRemoteService { + @Remote list(): SubagentList { return { ids: [] } } + @Remote prompt(request: PromptRequest): PromptResult { + return { accepted: request.text !== '' } + } + @Remote interruptByParent(id: string): void { } +} +``` + +The wrong version adds the same capability to the object that already carries other domains: + +```text +class ApiProxy { + @Remote goalList(): GoalList // goal domain + @Remote subagentPrompt(request): ... // subagent domain +} +``` + +One object then answers for both domains, so a change to either capability's activation policy, pagination, or event handling lands in the same place, and neither capability can move without the other. + +## `@Remote` marks capability methods + +`@Remote` carries four disciplines that a reviewer checks separately: it marks the concrete method that performs a capability; client-facing types come from the existing definitions through the generator instead of a hand-written copy; the payload is JSON and the remote method performs no output-side decode; and existing method and field names are additive. Each discipline removes a different failure — a wrapper hides which operation is exposed, a second hand-written type set drifts from the generated one, an output-side decode adds a second result path beside the codec the Gateway already runs, and a rename breaks every caller in the same change. + +- **Capability methods.** The annotation goes on the concrete method that performs the operation (`list`, `prompt`, `interruptByParent`), never on a wrapper that forwards to the real implementation. Judged by the decorated method's body: a body that only delegates means the annotation is on the wrong method. +- **Types from inference.** Client-facing types are aliased from what the generator produces — `type SessionRemote = TypertClientRemote['session']` — instead of being written a second time. Judged by a hand-written declaration whose fields repeat a generated Client type; [typert.md](../subsystems/typert.md) owns the generated maps and their declaration merges. +- **JSON on the wire, no output-side decode.** The **wire payload** of a Remote method is JSON, with binary content expressed field-level. The Host signature keeps what the Gateway already supports — lookup arguments resolved to Host objects, a trailing `AbortSignal`, a stream declared with `@Remote({ mode: 'stream' })` — and `@Remote` performs no decode on the way out; the generated codec and the Gateway validate the result. Judged by a decode, a stream reassembly, or a whole non-JSON value in the payload of a method carrying the decorator; [programming model](../api-gateway.md#programming-model) owns what may cross a Remote method. +- **Existing names are additive.** A migration does not rename a remote method or field that a Client already calls; a new semantic gets a new entry, and a rename is its own change with its own review. Judged by a diff that renames or removes a name already exposed through `ctx.remote.`. +- **Not covered.** A method without the decorator stays an ordinary service method with in-process callers; the four disciplines govern what becomes a Remote method, not what a service may implement. +- **Exception.** A rename or a new decode path states its benefit and the impact on existing callers first and needs the domain owner's confirmation; the author never self-approves. + +The generated map is the only source for Client-facing types, the decorator sits on the concrete method, and the payload stays JSON: + +```ts ignore-check +import { Remote, TypertRemoteService, type TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' + +// Client-facing types come from the generated map, never from a second hand-written set. +type SessionRemote = TypertClientRemote['session'] + +class SessionController extends TypertRemoteService { + @Remote('search') + search(query: string): SessionSearchValue { + return this.runSearch(query) + } +} +``` + +A hand-written second type set, a rename of a shipped name, and an output-side decode each break a different discipline: + +```text +type SessionRemote = { list(): ...; rename(id: string): ... } // hand-written copy +@Remote('renameSession') rename(...) // shipped name changed +@Remote decode(payload: Uint8Array): Result // output-side decode +``` + +The hand-written copy drifts from the generated type without failing a build, the rename breaks every existing Client call at once, and the decode replaces the generated codec with a second, unchecked result path. + +## `packages/api` carries generic orchestration only + +The packages under `packages/api` — the controllers, `gateway`, and `remotes` — carry transport, call shape, Session lifecycle, and other generic orchestration. A business branch never aggregates there: a decision that depends on concrete business state, handling bound to one feature, or a per-feature filter belongs in its own package or module and reaches this layer as projected data or through a registration surface. The criterion is where the logic belongs, not where the file sits, so a branch that only makes sense for one feature is a defect even when the file holding it is generic. + +- Judged by: where the branch reads its condition. An `if` or `switch` over feature state, a per-feature filter, or a helper named for one feature inside `packages/api` is the defect; the same decision taken in the owning package and consumed here as projected data or a registered entry is not. The package census shows the impact: `packages/api` holds nine packages — `account-controller`, `gateway`, `job-controller`, `remotes`, `session-controller`, `settings-controller`, `terminal-controller`, `workspace-controller`, `workspace-files` — and `session-controller` is the largest at 34 `src` files (terminal-controller 12, gateway 11, workspace-controller 10, workspace-files 8, job-controller 8, remotes 4, settings-controller 3, account-controller 2). +- Not covered: generic Session lifecycle, transport, and storage abstraction stay — `gateway` dispatch, `remotes` selection, and shared session-list and catalog reads are infrastructure, not business logic. [Business ownership](../api-gateway.md#business-ownership) states the same split from the Gateway side. +- Exception: none by default. Adding a business branch here needs an independent-package review, and that review answers why the logic cannot live in the package that owns the feature. +- Fix: move the decision into the owning package, publish its result as projected data or register it on a registry this layer already injects, and leave only the orchestration that reads it. + +The owning package decides and registers its unit, and the controller only reads the projected value: + +```ts ignore-check +// packages/workspace//src/index.ts: the rule lives with the feature it serves. +ctx.sessionProjections.register({ + key: 'durablePage', + stateSchema: durablePageState, + init: () => ({ durable: false }), + apply: applyDurablePage, + stateVersion: 1, +}) + +// packages/api/session-controller: generic orchestration reads the projected value. +const { durable } = ctx.sessionProjections.stateOf(session, 'durablePage') +``` + +The wrong version decides the rule inside the shared layer: + +```text +// packages/api/session-controller: a feature branch inside the shared layer +if (session.page.transport === 'durable') { /* durable-transport handling */ } +``` + +The rule then has a second home, and every later change to it has to find both while the shared layer keeps carrying one feature's semantics. diff --git a/docs/defensive-patterns/remote-and-api.zh.md b/docs/defensive-patterns/remote-and-api.zh.md new file mode 100644 index 0000000000..1a9c291d9c --- /dev/null +++ b/docs/defensive-patterns/remote-and-api.zh.md @@ -0,0 +1,114 @@ +# 远程与 API 规则 + +[English](remote-and-api.md) | 中文 + +`packages/api` 层及其对外远程方法的规则。调用模型本身见 [api-gateway.zh.md](../api-gateway.zh.md)。以下每条规则都给出要求、审查者的判定方式、不覆盖的内容、例外与批准人,以及一个正例与一个反例。 + +## 新的远程能力要有自己的 service + +新的远程业务能力就是一个新的 service——独立包或独立对象——`@Remote` 标在它的具体方法上。往既有的大 service 上一个方法一个方法地加,会让同一个对象同时承担 auth、Session、workspace 与事件载体,把逐领域的激活策略与历史分页混在一起,并让之后对任一能力的变更都要动所有这些面。按业务边界切,而不是按调用方数量切:当前只有一个调用方的能力也建独立 service;端点迁移是搬到新归属,而不是并进它正要离开的那个对象。 + +- 判定:新远程方法最终落在哪个对象上。把属于新业务线的方法加到已经承担另一条业务线的 service 上即为违反;修法是为新能力建一个以它命名的 service,并把 `@Remote` 标在它的具体方法上。迁移按同一标准判定——被搬走的方法出现在新归属上(如 `packages/subagent/subagent` 的 `SubagentRuntime`),而不是留在它离开的那个对象上。 +- 不覆盖:所有方法都服务同一条业务边界的 service 保持完整,该能力新增的私有辅助方法就属于它。需要独立协议的数据也不会变成新 service 上的 Remote 方法;会话事件流、分页、增量 reduce、projection 与实体子流保持各自独立的注册模型([gateway 边界](../api-gateway.zh.md#boundaries))。 +- 例外:无。只有一个调用方不构成合并的理由;业务边界本身有争议时,由对应领域专家作为架构问题先行裁定,再落地。 +- 修法:为新能力建独立 service 对象或包,把 `@Remote` 标在它的具体方法上,调用方经 `ctx.remote.` 使用。 + +subagent 能力拥有自己的 service,它的每个远程方法都是该 service 的具体方法: + +```ts ignore-check +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +interface SubagentList { ids: string[] } +interface PromptRequest { text: string } +interface PromptResult { accepted: boolean } + +class SubagentRuntime extends TypertRemoteService { + @Remote list(): SubagentList { return { ids: [] } } + @Remote prompt(request: PromptRequest): PromptResult { + return { accepted: request.text !== '' } + } + @Remote interruptByParent(id: string): void { } +} +``` + +反例把同一能力加到已经在承担其他领域的对象上: + +```text +class ApiProxy { + @Remote goalList(): GoalList // goal domain + @Remote subagentPrompt(request): ... // subagent domain +} +``` + +于是一个对象同时为两个领域作答:任一能力的激活策略、分页或事件处理变更都落在同一处,两个能力也无法各自搬迁。 + +## `@Remote` 标在能力方法上 + +`@Remote` 有四项纪律,审查者分别核对:标注执行能力的具体方法;面向 Client 的类型经生成器来自既有定义,而不是手写第二份;载荷只承载 JSON,远程方法不做输出侧 decode;既有的方法与字段名只增不改。每项纪律消除一类不同的缺陷——壳方法藏起真正暴露的是哪个操作,手写的第二份类型会与生成结果漂移,输出侧 decode 在 Gateway 已有的 codec 之外再造一条结果路径,改名则让所有调用方在同一次变更里一起断掉。 + +- **标能力方法。** 标注落在执行该操作的具体方法上(`list`、`prompt`、`interruptByParent`),绝不落在转发给真正实现的壳方法上。判定看被标注方法的函数体:函数体只做转发,标注就标错了方法。 +- **类型靠推断。** 面向 Client 的类型从生成结果取别名——`type SessionRemote = TypertClientRemote['session']`——而不是重写一份。判定看是否出现字段与生成的 Client 类型重复的手写声明;生成映射及其声明合并的正家是 [typert.zh.md](../subsystems/typert.zh.md)。 +- **线上载荷只承载 JSON、不做输出侧 decode。** Remote 方法的**线上载荷**是 JSON,二进制在字段级表达。Host 签名保留 Gateway 已支持的形式——经解析变成 Host 对象的 lookup 参数、末尾的 `AbortSignal`、以 `@Remote({ mode: 'stream' })` 声明的流式返回;`@Remote` 不做输出侧 decode,结果由生成的 codec 与 Gateway 校验。判定看带该标注的方法上是否出现 decode、流重组或整体非 JSON 值;可跨越 Remote 方法的内容正家是 [编程模型](../api-gateway.zh.md#programming-model)。 +- **存量名只增不改。** 迁移不改 Client 已在调用的远程方法名或字段名;新语义新增条目,改名是独立的一刀、单独评审。判定看 diff 是否改名或删除了已经经 `ctx.remote.` 暴露的名字。 +- **不覆盖。** 不带该标注的方法仍是有进程内调用者的普通 service 方法;这四项纪律约束的是什么成为 Remote 方法,而不是 service 可以实现什么。 +- **例外。** 确需改名或新增 decode 路径,须先说明收益与对既有调用方的影响,并取得对应领域专家的确认;作者不得自行批准。 + +生成的映射是面向 Client 类型的唯一来源,标注落在具体方法上,载荷保持 JSON: + +```ts ignore-check +import { Remote, TypertRemoteService, type TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' + +// Client-facing types come from the generated map, never from a second hand-written set. +type SessionRemote = TypertClientRemote['session'] + +class SessionController extends TypertRemoteService { + @Remote('search') + search(query: string): SessionSearchValue { + return this.runSearch(query) + } +} +``` + +手写第二份类型、改名已发布的名字、在远程方法上做输出侧 decode,各自破坏一项纪律: + +```text +type SessionRemote = { list(): ...; rename(id: string): ... } // hand-written copy +@Remote('renameSession') rename(...) // shipped name changed +@Remote decode(payload: Uint8Array): Result // output-side decode +``` + +手写的副本会与生成类型漂移而不会让构建失败,改名一次性打断所有既有 Client 调用,decode 则用第二条未经校验的结果路径替换生成的 codec。 + +## `packages/api` 只承载通用编排 + +`packages/api` 下的包——各 controller、`gateway`、`remotes`——承载传输、调用形态、Session 生命周期等通用编排。业务分支绝不在其中聚合:依赖具体业务状态的判断、与单个功能绑定的处理、逐功能的过滤,都归独立包或模块,并作为投影数据或经注册面接入本层。判据是逻辑的归属,而不是文件的位置,所以只对一个功能成立的业务分支即使放在通用文件里也是缺陷。 + +- 判定:分支的条件从哪里读取。在 `packages/api` 内对功能状态做 `if` 或 `switch`、写逐功能的过滤、或以某个功能命名的辅助方法,即为缺陷;同样的判断由归属包做出、在本层作为投影数据或注册条目消费,则不是。包普查是影响面的证据:`packages/api` 共 9 个包——`account-controller`、`gateway`、`job-controller`、`remotes`、`session-controller`、`settings-controller`、`terminal-controller`、`workspace-controller`、`workspace-files`——其中 `session-controller` 以 34 个 `src` 文件为最多(terminal-controller 12、gateway 11、workspace-controller 10、workspace-files 8、job-controller 8、remotes 4、settings-controller 3、account-controller 2)。 +- 不覆盖:通用 Session 生命周期、传输与存储抽象留下——`gateway` 的分发、`remotes` 的选择、共享的会话列表与目录读取属于基础设施,不是业务逻辑。[业务所有权](../api-gateway.zh.md#business-ownership)从 Gateway 一侧给出同一个划分。 +- 例外:默认无例外。在此新增业务分支须走独立包评审,评审要回答这些逻辑为什么不能住在拥有该功能的包里。 +- 修法:把判断移进归属包,将其结果作为投影数据发布、或注册到本层本就注入的注册表上,这里只留下读取它的编排。 + +归属包做出判断并注册自己的单元,controller 只读取投影值: + +```ts ignore-check +// packages/workspace//src/index.ts: the rule lives with the feature it serves. +ctx.sessionProjections.register({ + key: 'durablePage', + stateSchema: durablePageState, + init: () => ({ durable: false }), + apply: applyDurablePage, + stateVersion: 1, +}) + +// packages/api/session-controller: generic orchestration reads the projected value. +const { durable } = ctx.sessionProjections.stateOf(session, 'durablePage') +``` + +反例把这条规则的决定放进共享层: + +```text +// packages/api/session-controller: a feature branch inside the shared layer +if (session.page.transport === 'durable') { /* durable-transport handling */ } +``` + +这条规则于是有了第二个家,之后每次变更都要同时找到两处,而共享层继续承载某一个功能的语义。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2a08605a85..297872610b 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,14 +21,14 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: e3327c5492a593cb - zh: d43a5edcb11523ae + en: f0582a3a145a4d93 + zh: 9cab4abec6935b3c /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 /development-guide/contributor-reference/git-integrations: - en: dc98eda4f4021feb - zh: b08766708803cf10 + en: 4e782ec2ca27dd1f + zh: 7a60e8c0d60de3c8 /development-guide/contributor-reference/ci-gates: en: b64a914e41b1d285 zh: 6c031b507c3c0e84 @@ -39,8 +39,8 @@ en: 7ce6264a6a06dc4a zh: e12b684f2bef942b /development-guide/contributor-reference/application-commands: - en: 28a029c8768f0880 - zh: 67c17256e046624e + en: 98828fc2b972bf39 + zh: 1dc889a3d766bf35 /development-guide/contributor-reference/todo-markers: en: 85b38f78d87fc88f zh: 00d5eb625e7dfb61 diff --git a/docs/development.md b/docs/development.md index 668beec326..39e05c25ef 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,24 +74,38 @@ Host and Client stay two aggregate programs because both sides declaration-merge Six packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, `client/connection`, and `session-query/session-log-export`. `api/remotes`' Host entry participates in the Host Typert graph while its Client entry imports generated `/remote` declarations; `session-log-export` keeps Node archive production out of its browser controller. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) and [`session-log-export` README](../packages/session-query/session-log-export/README.md) explain their splits. -The root build follows the generated dependency order: +`pnpm run build:artifacts` emits every artifact in the generated dependency order, checking and compiling only the package references of each compiler face: ```sh -tsc -b tsconfig.host.json +pnpm run build:native-system +tsc -b tsdown --env.DSH_BUILD_FACE host pnpm --filter @deepseek-ai/dsh-desktop run bundle -tsc -b tsconfig.client.json +tsc -b tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build --artifacts-only` emits the same project and application artifacts while checking and compiling only the existing package references of each compiler face. Required Linux and Windows normal builds and public `typecheck` also check the repository-wide test and script programs. CI benchmark preparation additionally uses `--noCheck` for its disposable library outputs, relying on those required builds for diagnostics; other artifact builders keep package checking ([rationale](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md)). +`pnpm run build:check` runs only `tsc -p tsconfig.host.json` and `tsc -p tsconfig.client.json` concurrently. Project mode checks each aggregate's repository-wide test and script program against the package declarations already on disk and never compiles a reference, so `build:check` neither detects nor rebuilds missing or stale `build:artifacts` output; missing output surfaces as compiler errors. `pnpm run build` runs both step sets as one [scheduled](../scripts/run-gates.ts) dependency graph: each aggregate check starts once its own face's package compilation finishes and overlaps the remaining artifact steps. In all three commands `DSH_GATE_CONCURRENCY` bounds concurrent steps (`1` runs them in dependency order) and `DSH_GATE_FAIL_FAST=1` stops the remaining steps at the first failure; otherwise a failure skips only its dependents, so independent errors are all reported. A failed step prints its own output, and any failure exits non-zero without writing the client build record. + +Choose the command by what the change needs: + +| Situation | Command | +|---|---| +| Iterating on package sources and needing runnable artifacts | `pnpm run build:artifacts`; `dev:web` and `dev:desktop` run it before launching | +| Type-checking tests, scripts, benchmarks, or website code | `pnpm run build:check` after the latest `build:artifacts`; rerun `build:artifacts` first when package sources changed | +| Before declaring TypeScript work complete | `pnpm run build:check` after the latest `build:artifacts`, or `pnpm run build` | +| Complete verification, as the required CI build lanes run it | `pnpm run build` | +| Type checks without the Web and Desktop artifacts | `pnpm run typecheck` | +| Lint or documentation code-block checks | `pnpm run lint` or `pnpm run doc-typecheck`; each prepares `host-contracts` itself | + +PR artifact builders run `build:artifacts`; required Linux and Windows normal builds and public `typecheck` also check the repository-wide test and script programs. CI benchmark preparation additionally uses `--noCheck` for its disposable library outputs, relying on those required builds for diagnostics; other artifact builders keep package checking ([rationale](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md)). Both tsdown passes match `vendor/*`, `packages/*/*`, and `apps/cli`; the Host pass also matches `apps/desktop-host`. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. Tsdown builds the matched workspace members concurrently, so `apps/desktop`, whose main bundle inlines workspace devDependencies from their `lib/` output, bundles in its own step after the Host pass ([Desktop README](../apps/desktop/README.md#bundled-workspace-dependencies)). -Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. +Typert analyzes Host types, seeded by `tsconfig.host.json`, and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Host tsdown runs it as a plugin, and [`generate-typert.ts`](../scripts/generate-typert.ts) runs the same generation without bundling and writes byte-identical files. The bundle-free pass reads workspace packages only from source and fails when the Host program would load a package's `lib/` output, naming the import and the `paths` alias to add, so it can run beside Host package compilation. Client package compilation always follows one of the two passes because Client sources import the generated Remote declarations. `pnpm run typecheck` runs the `build.ts --scope typecheck` graph: Host package compilation beside the bundle-free Typert pass, then Client package compilation, with each aggregate checked in build mode (`tsc -b`) once its own face's packages compile; it skips the native addon, every bundle, Web, and the client build record. `lint`, `lint:fix`, and `doc-typecheck` read the generated declarations but not the Host aggregate check, so they prepare only Host package compilation and the bundle-free Typert pass (`compile-referenced-projects.ts host-contracts`). None of these commands writes bundles: the runnable `lib/` output stays as the last `build:artifacts` left it, so run `build:artifacts` before launching or testing built output. -`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs that complete build first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. +`pnpm run build` and `build:artifacts` embed the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; they also inherit other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful `build` or `build:artifacts` writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs `build:artifacts` first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. @@ -122,9 +136,9 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as a fast local checkpoint: -- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard. +- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, checks the staged diff for whitespace errors, and runs the vendor manifest guard. - `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit. -- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated Typert contracts, before the Client TypeScript check. +- `pre-push` runs `pnpm run typecheck`, which generates Typert contracts without bundling before the Client TypeScript check and checks both aggregates. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. @@ -132,6 +146,8 @@ Apart from the scoped staged-record verification, the hooks intentionally do not Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction. +Outside CI (`CI` other than `true`), default worker counts follow the [local worker budget](../scripts/parallelism.ts): 12 for gate schedulers, `build`, `typecheck`, Oxlint, and publint, and 48 for Vitest forks and local coverage. `doc-sync`, `test:docs`, `hygiene`, and `check:all` run at most 8 gates at once, in CI as well. `os.availableParallelism()` reports host CPUs rather than a container's CPU quota, so uncapped defaults oversubscribe shared hosts. CI keeps the runner's CPU count. `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` override the defaults. + ### CI gates The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. Coverage partitions reuse file timings scoped to their platform and runner pool to balance work within the configured worker limit. Required benchmarks run separately on standard GitHub-hosted Linux; the [benchmark runner reference](../benchmarks/AGENTS.md) owns routing and the job timeout. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. @@ -164,7 +180,7 @@ pnpm run demo:ptc -- "summarize this workspace" ### Application commands -Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build`; `dev:*` runs that build first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: +Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build` or `build:artifacts`; `dev:*` runs `build:artifacts` first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: ```sh pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) diff --git a/docs/development.zh.md b/docs/development.zh.md index 3154c4d988..c8b50343fd 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -78,24 +78,38 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 拆分 Host/Client tsconfig 的包有六个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection` 与 `session-query/session-log-export`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。 -根构建按生成依赖排序: +`pnpm run build:artifacts` 按生成依赖顺序产出全部产物,只检查并编译各 compiler face 的包 reference: ```sh -tsc -b tsconfig.host.json +pnpm run build:native-system +tsc -b tsdown --env.DSH_BUILD_FACE host pnpm --filter @deepseek-ai/dsh-desktop run bundle -tsc -b tsconfig.client.json +tsc -b tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build --artifacts-only` 产出相同的 project 和应用产物,但只检查并编译各 compiler face 的现有包 reference。必需的 Linux 与 Windows 普通构建以及公开 `typecheck` 还检查仓库级测试和脚本 program。CI benchmark 准备阶段还对其临时 library 输出使用 `--noCheck`,由这些必需构建负责诊断;其他产物 builder 保留包检查([理由](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md))。 +`pnpm run build:check` 只并发运行 `tsc -p tsconfig.host.json` 与 `tsc -p tsconfig.client.json`。project mode 依据磁盘上已有的包声明检查各 aggregate 的仓库级测试和脚本 program,从不编译 reference,因此 `build:check` 既不检测也不重建缺失或过期的 `build:artifacts` 产物;产物缺失时表现为编译器错误。`pnpm run build` 把两组步骤作为一张[调度](../scripts/run-gates.ts)依赖图运行:每个 aggregate 检查在本 face 的包编译完成后立即开始,与其余产物步骤重叠执行。三个命令中,`DSH_GATE_CONCURRENCY` 限制并发步骤数(`1` 按依赖顺序执行),`DSH_GATE_FAIL_FAST=1` 在第一个失败时停止其余步骤;否则失败只跳过其依赖方,因此会报告所有相互独立的错误。失败步骤打印自身输出,任一失败都以非零状态退出且不写 client build record。 + +按改动需要选择命令: + +| 场景 | 命令 | +|---|---| +| 迭代包源码并需要可运行的产物 | `pnpm run build:artifacts`;`dev:web` 与 `dev:desktop` 启动前会先执行它 | +| 检查测试、脚本、benchmarks 或 website 代码的类型 | 在最近一次 `build:artifacts` 之后运行 `pnpm run build:check`;包源码有改动时先重新运行 `build:artifacts` | +| 声明 TypeScript 工作完成之前 | 在最近一次 `build:artifacts` 之后运行 `pnpm run build:check`,或运行 `pnpm run build` | +| 完整验证(与 CI 必需的构建 lane 相同) | `pnpm run build` | +| 只做类型检查、不需要 Web 与 Desktop 产物 | `pnpm run typecheck` | +| lint 或文档代码块检查 | `pnpm run lint` 或 `pnpm run doc-typecheck`;它们各自先准备 `host-contracts` | + +PR 产物 builder 运行 `build:artifacts`;必需的 Linux 与 Windows 普通构建以及公开 `typecheck` 还检查仓库级测试和脚本 program。CI benchmark 准备阶段还对其临时 library 输出使用 `--noCheck`,由这些必需构建负责诊断;其他产物 builder 保留包检查([理由](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md))。 两次 tsdown 都匹配 `vendor/*`、`packages/*/*` 与 `apps/cli`,Host 阶段另外匹配 `apps/desktop-host`;两者都不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。tsdown 并发构建匹配到的 workspace 成员;`apps/desktop` 的主进程 bundle 需要从工作区 devDependencies 的 `lib/` 产物内联这些包,因此在 Host 阶段之后单独一步打 bundle([Desktop README](../apps/desktop/README.zh.md#bundled-workspace-dependencies))。 -Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 +Typert 以 `tsconfig.host.json` 为种子分析 Host 类型,生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。Host tsdown 以插件形式运行它,[`generate-typert.ts`](../scripts/generate-typert.ts) 则不打包地执行同一生成,写出逐字节相同的文件。不打包的这一轮只从源码读取工作区包;若 Host program 会载入某个包的 `lib/` 产物,它就失败并指出该 import 及应补的 `paths` 别名,因此可以与 Host 包编译并行。Client 源码 import 生成的 Remote 声明,所以 Client 包编译总在这两种生成之一之后。`pnpm run typecheck` 运行 `build.ts --scope typecheck` 图:Host 包编译与不打包的 Typert 生成并行,之后是 Client 包编译,每个 aggregate 在本 face 的包编译完成后以 build mode(`tsc -b`)检查;它跳过 native addon、所有 bundle、Web 以及 client build record。`lint`、`lint:fix` 与 `doc-typecheck` 读取生成的声明但不依赖 Host aggregate 检查,因此只准备 Host 包编译与不打包的 Typert 生成(`compile-referenced-projects.ts host-contracts`)。这些命令都不写 bundle:可运行的 `lib/` 产物保持上一次 `build:artifacts` 的状态,因此启动或测试构建产物之前先运行 `build:artifacts`。 -`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行这次完整构建(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 +`pnpm run build` 与 `build:artifacts` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次 `build` 或 `build:artifacts` 成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行 `build:artifacts`(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 @@ -109,6 +123,7 @@ pnpm run build `pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验包入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。 + ### 环境变量 真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证: @@ -126,9 +141,9 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: -- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; +- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; - `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查; -- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 Typert 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。 +- `pre-push` 运行 `pnpm run typecheck`;该命令先不打包地生成 Typert 约定,再运行 Client TypeScript 检查,并检查两个 aggregate。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 @@ -136,6 +151,8 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。 +在 CI 之外(`CI` 不为 `true`),默认 worker 数遵循[本地 worker 预算](../scripts/parallelism.ts):门禁调度器、`build`、`typecheck`、Oxlint 与 publint 为 12,Vitest fork 与本地覆盖率为 48。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多同时运行 8 个门禁,CI 中同样如此。`os.availableParallelism()` 报告的是宿主 CPU 数而不是容器的 CPU 配额,因此不设上限的默认值会在共享主机上超额订阅。CI 仍使用 runner 的 CPU 数。`DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest 的 `--maxWorkers` 可覆盖这些默认值。 + ### CI 门禁 keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。覆盖率分区复用按平台和运行器池隔离的文件耗时,在配置的 worker 上限内均衡工作量。必需 benchmark 在标准 GitHub 托管 Linux 上独立运行;[benchmark 运行器说明](../benchmarks/AGENTS.md)拥有路由及 job 超时。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 @@ -168,7 +185,7 @@ pnpm run demo:ptc -- "summarize this workspace" ### 应用命令 -Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 的产物;`dev:*` 先执行该构建再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: +Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 或 `build:artifacts` 的产物;`dev:*` 先执行 `build:artifacts` 再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: ```sh pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c36cd9801d..b40bcf5462 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,12 +22,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -63,7 +63,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2cc429dc63..9c7acd09bb 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,12 +24,12 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -65,7 +65,7 @@ | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -82,7 +82,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index e2e49e31f5..532c84896c 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -18,8 +18,8 @@ en: 38a55fc61b359ace zh: 567cae201b3a1bdf /glossary/loop-hierarchy: - en: 343a7cb31b8191c2 - zh: 6831415a7fe1726a + en: 6bf1ad7dd53f79cc + zh: 6231e68c769fdefa /glossary/ralph: en: 5059f8a377980b9d zh: 39bf11c10e5c0a84 diff --git a/docs/glossary.md b/docs/glossary.md index 1a9ac39a73..e30e4e2122 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -37,6 +37,7 @@ Domain vocabulary for DeepSeek Harness uses one canonical term per concept. Term - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. - **step** — one model request plus the tool executions caused by its response; a turn contains zero or more steps. - **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. +- **exchange** — consecutive [turns](#turn) from a turn whose first [step](#step) admits human input until the next such turn; turns woken by notices, such as settled subagents, goal rounds, or schedules, stay in the preceding exchange. Chat's next-message folding keeps the latest exchange expanded. ## Ralph diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index cd41876d41..68f5e4bf8c 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -37,6 +37,7 @@ DeepSeek Harness 的领域词汇为每个概念规定一个规范术语。各术 - **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。 - **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含零个或多个步骤。 - **Round**:承载一个轮次的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。 +- **交互段**:连续的[轮次](#turn),从首个[步骤](#step)接纳真人输入的轮次起,到下一个这样的轮次为止;由通知(子 agent 完成、Goal Round、定时任务等)唤醒的轮次归入前面的交互段。Chat 的「下次发送时收起」保持最新交互段展开。 ## Ralph diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index cf7009ff7f..5971f88600 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -127,6 +127,7 @@ | event log | 事件日志 | | | | | event stream | 事件流 | | | | | event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 | +| exchange | 交互段 | | 回合、交换 | Chat 中从真人输入开启的轮次起、到下一个这样的轮次为止的连续轮次;见术语表 | | Executive summary | 摘要 | | | 事故复盘标题用语 | | executor | 执行器 | | | | | expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 | diff --git a/docs/module-graph.md b/docs/module-graph.md index 815b1d99b6..fe417d9b07 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -175,6 +175,7 @@ flowchart TD pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] pkg_client_ui_schedule["client-ui-schedule"] + pkg_client_ui_secret_mask["client-ui-secret-mask"] pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_account["client-ui-settings-account"] @@ -219,6 +220,7 @@ flowchart TD pkg_agent_instructions["agent-instructions"] pkg_file_reference["file-reference"] pkg_file_reference_local["file-reference-local"] + pkg_harness_dev_context["harness-dev-context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -398,6 +400,7 @@ flowchart TD pkg_fs_ssh["fs-ssh"] pkg_sandbox_ssh["sandbox-ssh"] pkg_ssh["ssh"] + pkg_ssh_helper_runtime["ssh-helper-runtime"] pkg_subprocess_ssh["subprocess-ssh"] end subgraph group_storage["packages/storage"] @@ -449,6 +452,8 @@ flowchart TD pkg_web --> pkg_llm pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_app_boot --> pkg_home_paths + pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand pkg_client_connection --> pkg_scope pkg_computer_use --> pkg_brand @@ -458,11 +463,6 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_launch_environment - pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_lsp --> pkg_brand @@ -488,9 +488,8 @@ flowchart TD pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths - pkg_app_boot --> pkg_home_paths - pkg_app_boot --> pkg_launch_environment - pkg_app_boot --> pkg_system_prompt + pkg_hmr --> pkg_app_boot + pkg_hmr --> pkg_cmdline pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use pkg_experimental_inspector --> pkg_client_connection pkg_experimental_inspector --> pkg_client_modules @@ -499,6 +498,12 @@ flowchart TD pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver pkg_skill_badge --> pkg_skill + pkg_host_directory_picker_auto --> pkg_client_connection + pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver pkg_persona --> pkg_system_prompt @@ -526,8 +531,8 @@ flowchart TD pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence - pkg_hmr --> pkg_app_boot - pkg_hmr --> pkg_cmdline + pkg_config_editor --> pkg_app_boot + pkg_config_editor --> pkg_hmr pkg_experimental_speech_to_text --> pkg_settings pkg_experimental_translator --> pkg_credentials pkg_experimental_translator --> pkg_launch_environment @@ -595,8 +600,6 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace - pkg_config_editor --> pkg_app_boot - pkg_config_editor --> pkg_hmr pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text pkg_experimental_api_speech_to_text --> pkg_typert_protocol pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text @@ -635,6 +638,10 @@ flowchart TD pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol pkg_file_reference --> pkg_agent + pkg_harness_dev_context --> pkg_agent + pkg_harness_dev_context --> pkg_session + pkg_harness_dev_context --> pkg_session_projection + pkg_harness_dev_context --> pkg_system_prompt pkg_time_context --> pkg_agent pkg_time_context --> pkg_llm pkg_time_context --> pkg_session @@ -1006,6 +1013,7 @@ flowchart TD pkg_ssh --> pkg_session_projection pkg_ssh --> pkg_subprocess pkg_ssh --> pkg_subprocess_local + pkg_ssh --> pkg_util_values pkg_llm_replay --> pkg_compaction pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_llm @@ -1042,7 +1050,6 @@ flowchart TD pkg_api_settings_controller --> pkg_settings pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env - pkg_web_app --> pkg_system_prompt pkg_experimental_auto_review --> pkg_agent pkg_experimental_auto_review --> pkg_agent_instructions pkg_experimental_auto_review --> pkg_llm @@ -1491,6 +1498,7 @@ flowchart TD | [`client-ui-reference`](../packages/client/ui-reference) | `client` | — | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — | | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | +| [`client-ui-secret-mask`](../packages/client/ui-secret-mask) | `client` | — | | [`client-ui-session`](../packages/client/ui-session) | `client` | — | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | | [`client-ui-settings-account`](../packages/client/ui-settings-account) | `client` | — | @@ -1545,6 +1553,7 @@ flowchart TD | [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — | | [`session-format-v2-to-v3`](../packages/session/session-format-v2-to-v3) | `session` | — | | [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — | +| [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — | | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | @@ -1557,12 +1566,12 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | @@ -1579,11 +1588,12 @@ flowchart TD | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | -| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1599,7 +1609,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | +| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) | | [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) | @@ -1618,7 +1628,6 @@ flowchart TD | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | @@ -1630,6 +1639,7 @@ flowchart TD | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | +| [`harness-dev-context`](../packages/context/harness-dev-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | | [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | @@ -1703,14 +1713,14 @@ flowchart TD | [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | +| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 5002603ea9..792f2730d1 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -177,6 +177,7 @@ flowchart TD pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] pkg_client_ui_schedule["client-ui-schedule"] + pkg_client_ui_secret_mask["client-ui-secret-mask"] pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_account["client-ui-settings-account"] @@ -221,6 +222,7 @@ flowchart TD pkg_agent_instructions["agent-instructions"] pkg_file_reference["file-reference"] pkg_file_reference_local["file-reference-local"] + pkg_harness_dev_context["harness-dev-context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -400,6 +402,7 @@ flowchart TD pkg_fs_ssh["fs-ssh"] pkg_sandbox_ssh["sandbox-ssh"] pkg_ssh["ssh"] + pkg_ssh_helper_runtime["ssh-helper-runtime"] pkg_subprocess_ssh["subprocess-ssh"] end subgraph group_storage["packages/storage"] @@ -451,6 +454,8 @@ flowchart TD pkg_web --> pkg_llm pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_app_boot --> pkg_home_paths + pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand pkg_client_connection --> pkg_scope pkg_computer_use --> pkg_brand @@ -460,11 +465,6 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_launch_environment - pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_lsp --> pkg_brand @@ -490,9 +490,8 @@ flowchart TD pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths - pkg_app_boot --> pkg_home_paths - pkg_app_boot --> pkg_launch_environment - pkg_app_boot --> pkg_system_prompt + pkg_hmr --> pkg_app_boot + pkg_hmr --> pkg_cmdline pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use pkg_experimental_inspector --> pkg_client_connection pkg_experimental_inspector --> pkg_client_modules @@ -501,6 +500,12 @@ flowchart TD pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver pkg_skill_badge --> pkg_skill + pkg_host_directory_picker_auto --> pkg_client_connection + pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver pkg_persona --> pkg_system_prompt @@ -528,8 +533,8 @@ flowchart TD pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence - pkg_hmr --> pkg_app_boot - pkg_hmr --> pkg_cmdline + pkg_config_editor --> pkg_app_boot + pkg_config_editor --> pkg_hmr pkg_experimental_speech_to_text --> pkg_settings pkg_experimental_translator --> pkg_credentials pkg_experimental_translator --> pkg_launch_environment @@ -597,8 +602,6 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace - pkg_config_editor --> pkg_app_boot - pkg_config_editor --> pkg_hmr pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text pkg_experimental_api_speech_to_text --> pkg_typert_protocol pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text @@ -637,6 +640,10 @@ flowchart TD pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol pkg_file_reference --> pkg_agent + pkg_harness_dev_context --> pkg_agent + pkg_harness_dev_context --> pkg_session + pkg_harness_dev_context --> pkg_session_projection + pkg_harness_dev_context --> pkg_system_prompt pkg_time_context --> pkg_agent pkg_time_context --> pkg_llm pkg_time_context --> pkg_session @@ -1008,6 +1015,7 @@ flowchart TD pkg_ssh --> pkg_session_projection pkg_ssh --> pkg_subprocess pkg_ssh --> pkg_subprocess_local + pkg_ssh --> pkg_util_values pkg_llm_replay --> pkg_compaction pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_llm @@ -1044,7 +1052,6 @@ flowchart TD pkg_api_settings_controller --> pkg_settings pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env - pkg_web_app --> pkg_system_prompt pkg_experimental_auto_review --> pkg_agent pkg_experimental_auto_review --> pkg_agent_instructions pkg_experimental_auto_review --> pkg_llm @@ -1493,6 +1500,7 @@ flowchart TD | [`client-ui-reference`](../packages/client/ui-reference) | `client` | — | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — | | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | +| [`client-ui-secret-mask`](../packages/client/ui-secret-mask) | `client` | — | | [`client-ui-session`](../packages/client/ui-session) | `client` | — | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | | [`client-ui-settings-account`](../packages/client/ui-settings-account) | `client` | — | @@ -1547,6 +1555,7 @@ flowchart TD | [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — | | [`session-format-v2-to-v3`](../packages/session/session-format-v2-to-v3) | `session` | — | | [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — | +| [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — | | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | @@ -1559,12 +1568,12 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | @@ -1581,11 +1590,12 @@ flowchart TD | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | -| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1601,7 +1611,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | +| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) | | [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) | @@ -1620,7 +1630,6 @@ flowchart TD | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | @@ -1632,6 +1641,7 @@ flowchart TD | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | +| [`harness-dev-context`](../packages/context/harness-dev-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | | [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | @@ -1705,14 +1715,14 @@ flowchart TD | [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | +| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 458d219f5a..897241e388 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -177,8 +177,8 @@ en: 6c6a27578dff5828 zh: 9a5add2bc0df2d0a /session-persistence-event-catalog/events/session/session-title-llm-request-log-only: - en: 5eb108772a8498c9 - zh: 1fbc45e377084bae + en: 50d9e29d3d2bfe52 + zh: 3ff1d959cf7776b9 /session-persistence-event-catalog/events/session-log-deepseek: en: 7b827672c80aecdd zh: 7b827672c80aecdd @@ -1158,8 +1158,8 @@ en: 4ff17eae0b30056d zh: 6642afbaa87da3ab /session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata: - en: 390c1daad2342ce8 - zh: 07ce68854dce8ea4 + en: d7491d51e36cd1b6 + zh: 2fa7d1c33bace7fc /session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity: en: 9531468264499498 zh: 9da23c0ad2776563 @@ -1299,8 +1299,8 @@ en: 386989934d1bb62e zh: 69b8bc182a5acbd3 /session-persistence-event-catalog/resolved-persistence-types/string: - en: ecebde6d5ef1e880 - zh: 95ffb06f12850eb5 + en: 52f664fd6203444e + zh: dbebfaccbc666386 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1623,8 +1623,8 @@ en: c53129cf7214286c zh: 8c5183b6dabbf714 /session-persistence-event-catalog/resolved-persistence-types/kind-ptc-mode: - en: 26b3b9239392dcc9 - zh: f4c6c0195c9119fc + en: 8de77980e11a0562 + zh: 6de199e70a666a71 /session-persistence-event-catalog/resolved-persistence-types/kind-repeat-tool-reminder: en: e3a0ef98a197374e zh: 95e9363529a62cc3 @@ -1782,8 +1782,8 @@ en: 79db83dd2c430faf zh: 173f568241f5ce86 /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: aacb77f1cead58ba - zh: e6ce9dac4bdd07b7 + en: d23abbfb20eb4fc0 + zh: 65207e79db74e2fe /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 0d3b9ea113..8d50273dbd 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -843,7 +843,7 @@ Source: [`packages/session/session-title/src/index.ts:77`](../packages/session/s Types: [SessionTitleLlmRequestEventData](subsystems/session-title.md) -Source: [`packages/session/session-title-llm/src/index.ts:54`](../packages/session/session-title-llm/src/index.ts) +Source: [`packages/session/session-title-llm/src/index.ts:57`](../packages/session/session-title-llm/src/index.ts) ### `session-log-deepseek/*` @@ -4503,7 +4503,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` -Sources: [`packages/session/session-title-llm/src/index.ts:34`](../packages/session/session-title-llm/src/index.ts) +Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) | Property | Presence | Type | |---|---|---| @@ -5382,7 +5382,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -6747,7 +6747,7 @@ Sources: [`packages/session/session-title/src/types.ts:30`](../packages/session/ SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -Sources: [`packages/core/tools/src/ptc.ts:14`](../packages/core/tools/src/ptc.ts) +Sources: [`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts) | Property | Presence | Type | |---|---|---| @@ -7341,7 +7341,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:413`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index f48ef72c71..69c349097c 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -845,7 +845,7 @@ export type SessionEvent = { 类型:[SessionTitleLlmRequestEventData](subsystems/session-title.zh.md) -来源:[`packages/session/session-title-llm/src/index.ts:54`](../packages/session/session-title-llm/src/index.ts) +来源:[`packages/session/session-title-llm/src/index.ts:57`](../packages/session/session-title-llm/src/index.ts) ### `session-log-deepseek/*` @@ -4505,7 +4505,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` -来源:[`packages/session/session-title-llm/src/index.ts:34`](../packages/session/session-title-llm/src/index.ts) +来源:[`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5384,7 +5384,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -6749,7 +6749,7 @@ SHA-256: `b81e9de81a481b24e1e1f9adf038bfc59e57b4f35ce62f8f1e45284186f4f812` SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -来源:[`packages/core/tools/src/ptc.ts:14`](../packages/core/tools/src/ptc.ts) +来源:[`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7343,7 +7343,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:413`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-changes/2026-09-16-session-format-v4.i18n.yaml b/docs/persistence-changes/2026-09-16-session-format-v4.i18n.yaml index 22adb806b0..5bf6e8cc50 100644 --- a/docs/persistence-changes/2026-09-16-session-format-v4.i18n.yaml +++ b/docs/persistence-changes/2026-09-16-session-format-v4.i18n.yaml @@ -18,11 +18,11 @@ en: 6907136f28e48c90 zh: 02f8298485f3d8e7 /2026-09-16-session-format-v4/compatibility: - en: 62d76f6b66d04dc4 - zh: 736b1b7cc2f6084f + en: 0fc8ce7241c4ecbe + zh: d3f2c2812ecaca38 /2026-09-16-session-format-v4/verification: - en: f917c71292c98b17 - zh: 974c65afde0adb22 + en: 94b3c1b1d07bc87d + zh: af7ec37007c62ecb /2026-09-16-session-format-v4/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-09-16-session-format-v4.md b/docs/persistence-changes/2026-09-16-session-format-v4.md index 187acab093..370970ccb7 100644 --- a/docs/persistence-changes/2026-09-16-session-format-v4.md +++ b/docs/persistence-changes/2026-09-16-session-format-v4.md @@ -93,7 +93,7 @@ The [native V4 validation decision](../../.agents/notes/implemented/architecture The V3-to-V4 migration lifts released user-role tool results into tool-role messages with a required toolCallId and optional isError. Tool-result wrappers leave the content-block union. The migration preserves every admitted source event and inherited cut, and appends missing parent subagent/catalog records from retained direct-child logs in the same persistence root. Historical body restoration requires an explicit child-evidence set, including an empty set when no children are available to backfill. Missing, multiple, or unknown-version child descriptors skip that child’s backfill; conflicting identities, timestamps, or modes refuse migration without publication. Existing catalog facts remain. Historical read opens prepare the result in memory. Write opens revalidate child membership and revisions before publishing the current successor beside unchanged predecessor files. Delivery-generation checks keep historical acknowledgements from becoming active V4 watermarks. V3 readers refuse the newer generation. The finalized transition adds forked to turn/end.reason; exact-cut forks append child-owned error results and closers after the inherited marker. V4 admits checked not-started fork results with deterministic branch-specific IDs and wording; released V0–V3 validators and recorded predecessor generations remain unchanged. -The `request/header` schema also records the retired `system` key as forbidden. This declaration captures the existing native-reader refusal without changing stored data or prompt reconstruction; later permitting a value requires a version bump instead of being classified as an ordinary optional-field addition. +The `request/header` schema also records the retired `system` key as forbidden. This declaration captures the existing native-reader refusal without changing stored data or prompt reconstruction; later permitting a value requires compatibility review for a same-version change, or an explicit version bump, instead of being classified as an ordinary optional-field addition. Producer-owned sources replace released plugin wrappers through the [V3-to-V4 migration](../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification). The frozen rename table and collision rules preserve source fields and event coordinates; unknown producer attribution retains every own JSON property. Native read and write opens validate source fields before exposing the Session. Existing V4 files do not rerun the incoming migration edge. @@ -108,7 +108,7 @@ The PTC producer writes `source.kind: 'ptc-mode'`. The V3-to-V4 edge retains `to The focused Session, agent-loop, Session Controller, V4, chat-view, and compaction suites passed 1,523 tests across 63 files after integration of exact-cut forks. V4 fork tests retain original IDs and text across encoding, decoding, and restoration, reject malformed results, and validate nested inherited cuts. The tool-role migration tests and SDK snapshot refresh also passed in the originating change; the built Python runtime sdk-snapshot scenario passed, and focused pi-ai and auto-review coverage passed 351 tests with 100% coverage of the three affected modules. -The generated request-header reservation regression and existing retired-syntax and Session surface tests pass 65 tests across three files. The generated field retains optional `never`; permitting an optional string produces a version-bump diagnostic while the native reader still refuses the retired key. +The generated request-header reservation regression and existing retired-syntax and Session surface tests passed 65 tests across three files. The generated field retains optional `never`; the current classifier marks permitting an optional string as requiring compatibility review, while the native reader still refuses the retired key. The producer-source and request-input checks pass 447 tests across 12 files, including provider equality, request-only type rejection, user-attribution retention, source migration, and native source admission. Comparing the generated inventories with the tool-role parent reports four changed roots and 447 unchanged type fingerprints. diff --git a/docs/persistence-changes/2026-09-16-session-format-v4.zh.md b/docs/persistence-changes/2026-09-16-session-format-v4.zh.md index 16a1793797..f9c25f9b32 100644 --- a/docs/persistence-changes/2026-09-16-session-format-v4.zh.md +++ b/docs/persistence-changes/2026-09-16-session-format-v4.zh.md @@ -93,7 +93,7 @@ changes: V3-to-V4 迁移将已发布的 user 角色工具结果提升为 tool 角色消息,包含必需的 toolCallId 和可选的 isError。工具结果包装不再属于内容块联合。迁移保留每个已接纳源事件和继承切分点,并根据同一持久化根目录中保留的直属子 Session 日志追加缺失的父级 subagent/catalog 记录。历史正文恢复要求显式提供子日志证据集合;没有可供补全的子日志时也须传入空集合。子日志的 descriptor 缺失、多条或版本未知时,跳过该子项的补全;身份、时间戳或模式冲突会拒绝迁移且不发布后继。已有 catalog 事实保持不变。历史读取打开在内存中准备结果。写入打开在重新校验子项成员与修订后,将当前后继发布到未修改的前代文件旁。Delivery generation 校验防止历史确认成为有效的 V4 水位。V3 读取方拒绝更新的 generation。已定稿迁移向 turn/end.reason 添加 forked。精确切点的 fork 在继承标记之后追加子会话自有的错误结果和结束事件。V4 接纳经过校验、使用确定性分支 ID 和文案的未启动 fork 结果;已发布的 V0–V3 校验器和已记录的前驱代际保持不变。 -`request/header` schema 还将退役的 `system` 键记录为禁止字段。这项声明记录已有的原生读取拒绝规则,不改变存储数据或提示词重建;以后允许该字段携带值时,必须提升格式版本,而不能归类为普通可选字段添加。 +`request/header` schema 还将退役的 `system` 键记录为禁止字段。这项声明记录已有的原生读取拒绝规则,不改变存储数据或提示词重建;以后允许该字段携带值时,同版本变更必须经过兼容性评审,或显式提升格式版本,而不能归类为普通可选字段添加。 生产者拥有的 source 通过 [V3→V4 迁移](../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)替代已发布的 plugin wrapper。冻结的重命名表和冲突规则保留 source 字段与事件坐标;未知生产者归属保留每个自有 JSON 属性。原生读取和写入打开在公开 Session 之前校验 source 字段。已有 V4 文件不会重新运行迁入边。 @@ -108,7 +108,7 @@ PTC 生产者写入 `source.kind: 'ptc-mode'`。V3→V4 迁入边保留 `tools-c 精确切点 fork 集成后,聚焦的 Session、agent-loop、Session Controller、V4、chat-view 和 compaction 测试共 63 个文件、1,523 个测试通过。V4 fork 测试确认编码、解码和恢复保留原始 ID 与文本,拒绝格式错误的结果,并验证嵌套继承切点。工具角色迁移测试和 SDK 快照刷新也在原始变更中通过;构建后的 Python runtime sdk-snapshot 场景通过,定向 pi-ai 与 auto-review 覆盖率检查通过 351 个测试,三个受影响模块覆盖率均为 100%。 -生成的请求头保留字段回归测试以及已有的退役语法和 Session surface 测试共通过三个文件中的 65 项测试。生成字段保留可选的 `never`;允许可选字符串会产生必须提升版本的诊断,而原生读取方仍拒绝该退役键。 +生成的请求头保留字段回归测试以及已有的退役语法和 Session surface 测试曾通过三个文件中的 65 项测试。生成字段保留可选的 `never`;当前分类器将允许可选字符串标记为需要兼容性评审,而原生读取方仍拒绝该退役键。 Producer-source 与 request-input 检查共通过 12 个文件中的 447 项测试,覆盖 provider 等价性、仅供请求使用的输入的类型拒绝、user 归属保留、source 迁移及原生 source 准入。与 tool-role 父层的生成目录相比,有四个 root 发生变化,447 个类型指纹保持不变。 diff --git a/docs/persistence-changes/README.i18n.yaml b/docs/persistence-changes/README.i18n.yaml index 30537db444..aa185ca392 100644 --- a/docs/persistence-changes/README.i18n.yaml +++ b/docs/persistence-changes/README.i18n.yaml @@ -18,11 +18,11 @@ en: 6cd0f79a9455bdf2 zh: a5978b7834c0802b /persistence-type-change-records/compatibility-rules: - en: 62ce78d5618572b6 - zh: 023021d431f8a8e6 + en: 218b28a5096b5e7d + zh: 5b987c852e204893 /persistence-type-change-records/history-and-limitations: - en: 10a426d36c41548b - zh: ef03b693d9d9be26 + en: 705a215ce0dd5d82 + zh: a3b3e46231dae6ce /persistence-type-change-records/dev-note: en: 5de1d6304e00c58f zh: 8d8225361cfa29e5 diff --git a/docs/persistence-changes/README.md b/docs/persistence-changes/README.md index 4f6cf773a9..79e1401414 100644 --- a/docs/persistence-changes/README.md +++ b/docs/persistence-changes/README.md @@ -46,25 +46,27 @@ The [record template](../../.agents/skills/dsh-doc/templates/persistence-change. ## Compatibility rules -Every detected structural change requires an acknowledgement. Record creation and update infer the minimum decision from these fixed rules; an explicit `--decision` is a checked assertion. The rules apply to the complete change, so an allowed change cannot hide a simultaneous breaking change. +Every detected structural change requires an acknowledgement. Keep the Session version when older readers preserve the required meaning of accepted data, safely ignore optional features while retaining opaque records, or refuse unsupported required data before execution, mutation, or recovery truncation. New readers must preserve historical meaning and readability. Bump only when unsafe interpretation cannot be prevented by an effective discriminator. Unknown fields or values count only when existing readers actually handle them safely; type differences alone cannot establish that behavior. See the [reader-compatibility decision](../../.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md). -| Detected change | Minimum decision | +| Detected change | Recording requirement | |---|---| | Add an optional event-body property, including its complete subtree | `same-version` | | Make a required event-body property optional | `same-version` | | Add an ordinary event type | `same-version` | | Add a higher numeric `data.version` to an ordinary event while retaining every old payload alternative under the existing compatibility rules | `same-version` | | Add an explicitly qualified attribution kind to a user/developer source slot whose before and after schemas carry the same supported policy | `same-version` | -| Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | `version-bump` | -| Change the Session header or event envelope | `version-bump` | +| Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | Compatibility review for `same-version`, or explicit `version-bump` | +| Change the Session header or event envelope | Compatibility review for `same-version`; a header version change requires `version-bump` | -A finalized checkpoint protects the accepted baseline without replacing these compatibility rules. At V4, optional additions, ordinary events, and qualified attribution additions can receive new same-version records. Breaking differences require a higher writer version and an acknowledgement containing its own header increase. The accepted V4 record cannot be updated to reuse its original 3→4 transition. +The classifier identifies established same-version cases and changes requiring compatibility review; it cannot decide semantic safety. The latter require an explicit `--decision same-version` when recording or updating without a version increase. The acknowledgement’s Compatibility section explains actual reader behavior in both directions, and Verification records the executed checks. Review the complete transition, including simultaneous changes. An explicit `version-bump` instead requires its own increasing `SessionHeader.version` transition; a same-version decision cannot waive a header version change. Finalized records remain immutable, so a later record cannot reuse the accepted 3→4 transition. -An ordinary event may add payload alternatives with a required, nonnegative integer `data.version` greater than every existing payload version, provided every existing alternative remains readable under the compatibility rules above. Optional property additions and required-to-optional changes may accompany the new version; each retained alternative is compared separately. The reader must retain support for the old payloads; adding variants at an existing version, dropping old versions, and changing the Session header or envelope remain breaking. Older readers may reject the new payload version. The [catalog acknowledgement](2026-09-20-unknown-child-catalog.md) records one such transition. +The CLI requires that explicit choice before generating a flagged same-version record. CI reads the recorded `decision: same-version` and accepts it when the ordinary history, schema, and header-version constraints hold, whether the record was generated or edited manually. Verification cannot establish how the record was authored or whether a human reviewed it. Reviewers compare base and head inventories with [`persistence-review`](../cookbook/reviewing-persistence-type-changes.md#generate); `--check` validates current-tree consistency and cannot reveal a transition already covered by an acknowledgement. -The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated breaking changes remain strict. Multiple context-form alternatives with the same wire kind form one group. +An ordinary event may add payload alternatives with a required, nonnegative integer `data.version` greater than every existing payload version, provided every existing alternative remains readable under the established same-version rules above. Optional property additions and required-to-optional changes may accompany the new version; each retained alternative is compared separately. Other payload changes require compatibility review. Older readers may reject the new payload version. The [catalog acknowledgement](2026-09-20-unknown-child-catalog.md) records one such transition. -A same-version explanation states how old records remain readable and how older readers handle new records. Optional additions explain why older readers can ignore them without changing replay; a new payload version records the older-reader rejection. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). +The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated structural changes require compatibility review. Multiple context-form alternatives with the same wire kind form one group. + +Every same-version explanation states how old records remain readable and how older readers handle new records, including established cases that need no explicit CLI decision. A feature absent from a log must retain the old behavior; dispensable features must be safe to ignore; required features need an effective refusal signal. Unsupported-feature refusal must not become corruption recovery or tail truncation. For required-to-optional changes, explain how readers handle an absent value. Reviewers assess behavior and evidence; the checker validates the declaration and type classification. A version-bump record follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). When a reader rejects a JSON property by name, declare the property as optional `never` and mark it with argument-free `@persistenceReserved`. The extractor retains the forbidden field, so allowing a JSON value later requires an existing-field type change. Required or JSON-valued properties cannot carry this marker. Unmarked optional `never` and `undefined` properties retain their existing omission behavior. @@ -75,7 +77,7 @@ One baseline records the complete initial inventory. Later records use their pre Accepted records describe historical transitions; preserve their machine declarations and schema snapshots when adding a successor. An unaccepted terminal record can be refreshed explicitly; the command rejects baselines, records with dependants, and checkpoint-locked records before producing artifacts. Verification checks retained checkpoint hashes; it does not prove that the checkpoints and their authority record were never edited together. No Git ref, remote service, or released checkout supplies the baseline. -Digests describe declared persistence types, not runtime validation or behavior. Ordinary comments, source locations, alias names, and harmless declaration reordering do not affect them. Compatibility annotations are recorded policy data and do affect digests. Unannotated version-1 snapshots retain their original normalization, fingerprints, and strict classification; policy-bearing graphs use a separate fingerprint domain. Object fields, union alternatives, intersection operands, and index signatures can be reordered when their resolved types stay the same; tuple positions and numeric enum values remain significant. Catalog text and source locations may still change, so regenerate stale artifacts without adding an acknowledgement for an unchanged digest. Opaque types such as `unknown` expose no hidden structure to compare. Behavior-only changes and structures hidden inside opaque values are outside this mechanism's scope. The [decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) records these trade-offs. +Digests describe declared persistence types, not runtime validation or behavior. Ordinary comments, source locations, alias names, and harmless declaration reordering do not affect them. Compatibility annotations are recorded policy data and do affect digests. Unannotated version-1 snapshots retain their original normalization and fingerprints; policy-bearing graphs use a separate fingerprint domain. Object fields, union alternatives, intersection operands, and index signatures can be reordered when their resolved types stay the same; tuple positions and numeric enum values remain significant. Catalog text and source locations may still change, so regenerate stale artifacts without adding an acknowledgement for an unchanged digest. Opaque types such as `unknown` expose no hidden structure to compare. Behavior-only changes and structures hidden inside opaque values are outside this mechanism's scope. The [decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) records these trade-offs. ## Dev Note diff --git a/docs/persistence-changes/README.zh.md b/docs/persistence-changes/README.zh.md index 5754ff07c1..c7efb7f6cb 100644 --- a/docs/persistence-changes/README.zh.md +++ b/docs/persistence-changes/README.zh.md @@ -46,25 +46,27 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc ## 兼容性规则 -每个检测到的结构变更都需要确认。创建和更新记录依据这些固定规则推断最低决策;显式 `--decision` 是受检查的断言。规则应用于整个变更,因此允许的变更不能掩盖同时发生的破坏性变更。 +每个检测到的结构变更都需要确认。当旧读取器保留所接受数据的必要含义、安全忽略可选功能并保留不透明记录,或在执行、修改及恢复截断前拒绝不受支持的必要数据时,可以保留 Session 版本。新读取器必须保留历史含义和可读性。只有有效判别信息无法阻止不安全解释时,才提升版本。未知字段或值只有在已有读取器实际安全处理它们时才有效;类型差异本身无法证明这种行为。参见[读取器兼容性决策](../../.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.zh.md)。 -| 检测到的变更 | 最低决策要求 | +| 检测到的变更 | 记录要求 | |---|---| | 添加可选事件体属性,包括其完整子树 | `same-version` | | 将必选事件体属性改为可选 | `same-version` | | 添加普通事件类型 | `same-version` | | 为普通事件增加更高的数字 `data.version`,并按现有兼容性规则保留所有旧载荷分支 | `same-version` | | 在前后 schema 均带有相同受支持策略的 user/developer 消息源字段中,添加显式声明为归属信息的 kind | `same-version` | -| 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `version-bump` | -| 更改会话头或事件封装 | `version-bump` | +| 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `same-version` 需兼容性评审,或显式 `version-bump` | +| 更改会话头或事件封装 | `same-version` 需兼容性评审;头版本变化要求 `version-bump` | -定稿检查点保护已接受基线,不替换这些兼容性规则。在 V4 中,可选新增、普通事件及符合条件的归属 kind 新增可以使用新的同版本记录。破坏性差异要求更高的写入器版本,以及包含自身头版本递增的确认记录。不能更新已接受 V4 记录以复用其原有 3→4 转换。 +分类器识别已明确允许的同版本情形及需要兼容性评审的变更,不能判定语义安全性。后者在不升版本的记录或更新操作中要求显式提供 `--decision same-version`。确认记录的兼容性部分说明两个方向上的实际读取器行为,验证部分记录已执行的检查。评审覆盖整个转换,包括同时发生的其他变更。显式 `version-bump` 则要求自身包含 `SessionHeader.version` 递增转换;同版本决策不能豁免头版本变化。定稿记录保持不可变,因此后续记录不能复用已接受的 3→4 转换。 -普通事件可增加具有必选、非负整数 `data.version` 的载荷分支,新版本必须高于所有已有载荷版本,且所有已有分支按上述兼容性规则保持可读。可选属性新增和必选改可选可以与新版本一同引入;每个保留分支均独立比较。读取器必须保留旧载荷支持;在已有版本中增加分支、移除旧版本,以及修改 Session header 或事件封装仍属于破坏性变更。旧读取器可能拒绝新载荷版本。[Catalog 确认记录](2026-09-20-unknown-child-catalog.zh.md) 记录了此类演进。 +CLI 在生成被标记的同版本记录前要求该显式选择。CI 读取所记录的 `decision: same-version`;只要通常的历史、schema 与头版本约束成立,无论记录由工具生成还是手工编辑,都会接受。验证无法确定记录的编写方式,也无法证明人类已评审。评审者使用 [`persistence-review`](../cookbook/reviewing-persistence-type-changes.zh.md#generate) 比较 base 与 head 清单;`--check` 验证当前目录一致性,无法显示已被确认记录覆盖的转换。 -提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关破坏性变更继续采用严格规则。同一 wire kind 的多个上下文形式分支归为一组。 +普通事件可增加具有必选、非负整数 `data.version` 的载荷分支,新版本必须高于所有已有载荷版本,且所有已有分支按上述明确允许的同版本规则保持可读。可选属性新增和必选改可选可以与新版本一同引入;每个保留分支均独立比较。其他载荷变更需要兼容性评审。旧读取器可能拒绝新载荷版本。[Catalog 确认记录](2026-09-20-unknown-child-catalog.zh.md)记录了此类演进。 -同版本说明须解释旧记录如何保持可读,以及旧读取器如何处理新记录。可选新增须说明旧读取器为何可以忽略它而不改变回放;新增载荷版本须记录旧读取器的拒绝行为。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 +提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关结构变更需要兼容性评审。同一 wire kind 的多个上下文形式分支归为一组。 + +每项同版本说明都须解释旧记录如何保持可读,以及旧读取器如何处理新记录,包括无需显式 CLI 决策的明确允许情形。日志未使用某功能时必须保留旧行为;可舍弃功能必须能安全忽略;必要功能需要有效的拒绝信号。不受支持功能的拒绝不能变成损坏恢复或尾部截断。对于必选改可选的变更,说明读取器如何处理缺失值。审阅者判断行为和证据;检查器校验声明及类型分类。升版本记录遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 当读取器按属性名拒绝 JSON 字段时,将该属性声明为可选 `never`,并添加无参数的 `@persistenceReserved` 标记。提取器保留这一禁止字段,因此后续允许 JSON 值属于已有字段类型变更。必选属性或允许 JSON 值的属性不能携带该标记。未标记的可选 `never` 和 `undefined` 属性保留现有省略行为。 @@ -75,7 +77,7 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc 已接受记录描述历史转换;添加后继时须保留其机器声明和 schema 快照。尚未接受的末端记录可以显式刷新;命令在生成产物前拒绝基线、被依赖的记录和被检查点锁定的记录。验证检查所保留检查点的哈希,但不证明检查点及其真源记录从未被一起修改。基线不依赖 Git 引用、远端服务或已发布版本的检出目录。 -摘要描述声明的持久化类型,不描述运行时验证或行为。普通注释、源码位置、别名名称和无语义变化的声明重排不影响摘要。兼容性标记是记录的策略数据,会影响摘要。不带标记的版本 1 快照保留原有规范化、指纹和严格分类;包含策略的图使用独立的指纹域。对象字段、联合类型分支、交叉类型操作数和索引签名可以在解析类型不变时重排;元组位置和数字枚举值仍然影响摘要。目录文本和源码位置仍可能变化,因此应重新生成陈旧产物,无需为未变的摘要添加确认记录。`unknown` 等不透明类型不提供可比较的内部结构。纯行为变更以及不透明值中隐藏的结构不在本机制范围内。[决策记录](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)说明这些取舍。 +摘要描述声明的持久化类型,不描述运行时验证或行为。普通注释、源码位置、别名名称和无语义变化的声明重排不影响摘要。兼容性标记是记录的策略数据,会影响摘要。不带标记的版本 1 快照保留原有规范化和指纹;包含策略的图使用独立的指纹域。对象字段、联合类型分支、交叉类型操作数和索引签名可以在解析类型不变时重排;元组位置和数字枚举值仍然影响摘要。目录文本和源码位置仍可能变化,因此应重新生成陈旧产物,无需为未变的摘要添加确认记录。`unknown` 等不透明类型不提供可比较的内部结构。纯行为变更以及不透明值中隐藏的结构不在本机制范围内。[决策记录](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)说明这些取舍。 ## 开发备注 diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.i18n.yaml index 0ac31ac4ee..202343b8f7 100644 --- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-0-1-rc-2/structural-changes: - en: bdf6c95697ae5816 - zh: 0332dec60181ab4a + en: 7cd96972b43b0cb0 + zh: 7e61c1098f402657 /persistence-release-dsh-v0-0-1-rc-2/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.md b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.md index f9deeccf24..621d74281c 100644 --- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.md +++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.md @@ -191,58 +191,58 @@ changes: -Detected 45 changed roots and 48 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 45 changed roots and 48 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` | -| `event:agent-preset/selected.ignorable` | `optional-property-added` | `version-bump` | -| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` | -| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/asked.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/decided.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/policy.ignorable` | `optional-property-added` | `version-bump` | -| `event:assistant/chunk.ignorable` | `optional-property-added` | `version-bump` | -| `event:assistant/message.ignorable` | `optional-property-added` | `version-bump` | -| `event:command/done.ignorable` | `optional-property-added` | `version-bump` | -| `event:command/run.ignorable` | `optional-property-added` | `version-bump` | -| `event:compact/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:compact/prune.ignorable` | `optional-property-added` | `version-bump` | -| `event:compact/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:compact/summary.ignorable` | `optional-property-added` | `version-bump` | -| `event:feedback/record.ignorable` | `optional-property-added` | `version-bump` | -| `event:goal/change.ignorable` | `optional-property-added` | `version-bump` | -| `event:hook/invoked.ignorable` | `optional-property-added` | `version-bump` | -| `event:hook/result.ignorable` | `optional-property-added` | `version-bump` | -| `event:llm/retry.ignorable` | `optional-property-added` | `version-bump` | -| `event:llm/retry-started.ignorable` | `optional-property-added` | `version-bump` | -| `event:permission/preset.ignorable` | `optional-property-added` | `version-bump` | -| `event:plan/mode.ignorable` | `optional-property-added` | `version-bump` | -| `event:request/context.ignorable` | `optional-property-added` | `version-bump` | -| `event:request/header.ignorable` | `optional-property-added` | `version-bump` | -| `event:sandbox/mode.ignorable` | `optional-property-added` | `version-bump` | -| `event:schedule/change` | `root-added` | `same-version` | -| `event:session/end-seed.ignorable` | `optional-property-added` | `version-bump` | -| `event:session/title.ignorable` | `optional-property-added` | `version-bump` | -| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` | -| `event:session/title-llm-request.ignorable` | `optional-property-added` | `version-bump` | -| `event:step/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:step/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:subagent/descriptor.ignorable` | `optional-property-added` | `version-bump` | -| `event:todo/write.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool-workflow/agent-end` | `root-added` | `same-version` | -| `event:tool-workflow/agent-start` | `root-added` | `same-version` | -| `event:tool-workflow/run-end` | `root-added` | `same-version` | -| `event:tool-workflow/run-start` | `root-added` | `same-version` | -| `event:tool/call.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/result.ignorable` | `optional-property-added` | `version-bump` | -| `event:turn/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:turn/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:user/message.data.source` | `union-variants-changed` | `version-bump` | -| `event:user/message.ignorable` | `optional-property-added` | `version-bump` | -| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `version-bump` | +| `SessionEventEnvelope` | `union-variants-changed` | `review-required` | +| `event:agent-preset/selected.ignorable` | `optional-property-added` | `review-required` | +| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` | +| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/asked.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/decided.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/policy.ignorable` | `optional-property-added` | `review-required` | +| `event:assistant/chunk.ignorable` | `optional-property-added` | `review-required` | +| `event:assistant/message.ignorable` | `optional-property-added` | `review-required` | +| `event:command/done.ignorable` | `optional-property-added` | `review-required` | +| `event:command/run.ignorable` | `optional-property-added` | `review-required` | +| `event:compact/end.ignorable` | `optional-property-added` | `review-required` | +| `event:compact/prune.ignorable` | `optional-property-added` | `review-required` | +| `event:compact/start.ignorable` | `optional-property-added` | `review-required` | +| `event:compact/summary.ignorable` | `optional-property-added` | `review-required` | +| `event:feedback/record.ignorable` | `optional-property-added` | `review-required` | +| `event:goal/change.ignorable` | `optional-property-added` | `review-required` | +| `event:hook/invoked.ignorable` | `optional-property-added` | `review-required` | +| `event:hook/result.ignorable` | `optional-property-added` | `review-required` | +| `event:llm/retry.ignorable` | `optional-property-added` | `review-required` | +| `event:llm/retry-started.ignorable` | `optional-property-added` | `review-required` | +| `event:permission/preset.ignorable` | `optional-property-added` | `review-required` | +| `event:plan/mode.ignorable` | `optional-property-added` | `review-required` | +| `event:request/context.ignorable` | `optional-property-added` | `review-required` | +| `event:request/header.ignorable` | `optional-property-added` | `review-required` | +| `event:sandbox/mode.ignorable` | `optional-property-added` | `review-required` | +| `event:schedule/change` | `root-added` | `not-required` | +| `event:session/end-seed.ignorable` | `optional-property-added` | `review-required` | +| `event:session/title.ignorable` | `optional-property-added` | `review-required` | +| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` | +| `event:session/title-llm-request.ignorable` | `optional-property-added` | `review-required` | +| `event:step/end.ignorable` | `optional-property-added` | `review-required` | +| `event:step/start.ignorable` | `optional-property-added` | `review-required` | +| `event:subagent/descriptor.ignorable` | `optional-property-added` | `review-required` | +| `event:todo/write.ignorable` | `optional-property-added` | `review-required` | +| `event:tool-workflow/agent-end` | `root-added` | `not-required` | +| `event:tool-workflow/agent-start` | `root-added` | `not-required` | +| `event:tool-workflow/run-end` | `root-added` | `not-required` | +| `event:tool-workflow/run-start` | `root-added` | `not-required` | +| `event:tool/call.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/result.ignorable` | `optional-property-added` | `review-required` | +| `event:turn/end.ignorable` | `optional-property-added` | `review-required` | +| `event:turn/start.ignorable` | `optional-property-added` | `review-required` | +| `event:user/message.data.source` | `union-variants-changed` | `review-required` | +| `event:user/message.ignorable` | `optional-property-added` | `review-required` | +| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.zh.md b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.zh.md index 32962d394e..b0e2b58248 100644 --- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.zh.md @@ -191,58 +191,58 @@ changes: -检测到 45 个根类型变化、48 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 45 个根类型变化、48 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` | -| `event:agent-preset/selected.ignorable` | `optional-property-added` | `version-bump` | -| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` | -| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/asked.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/decided.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/policy.ignorable` | `optional-property-added` | `version-bump` | -| `event:assistant/chunk.ignorable` | `optional-property-added` | `version-bump` | -| `event:assistant/message.ignorable` | `optional-property-added` | `version-bump` | -| `event:command/done.ignorable` | `optional-property-added` | `version-bump` | -| `event:command/run.ignorable` | `optional-property-added` | `version-bump` | -| `event:compact/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:compact/prune.ignorable` | `optional-property-added` | `version-bump` | -| `event:compact/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:compact/summary.ignorable` | `optional-property-added` | `version-bump` | -| `event:feedback/record.ignorable` | `optional-property-added` | `version-bump` | -| `event:goal/change.ignorable` | `optional-property-added` | `version-bump` | -| `event:hook/invoked.ignorable` | `optional-property-added` | `version-bump` | -| `event:hook/result.ignorable` | `optional-property-added` | `version-bump` | -| `event:llm/retry.ignorable` | `optional-property-added` | `version-bump` | -| `event:llm/retry-started.ignorable` | `optional-property-added` | `version-bump` | -| `event:permission/preset.ignorable` | `optional-property-added` | `version-bump` | -| `event:plan/mode.ignorable` | `optional-property-added` | `version-bump` | -| `event:request/context.ignorable` | `optional-property-added` | `version-bump` | -| `event:request/header.ignorable` | `optional-property-added` | `version-bump` | -| `event:sandbox/mode.ignorable` | `optional-property-added` | `version-bump` | -| `event:schedule/change` | `root-added` | `same-version` | -| `event:session/end-seed.ignorable` | `optional-property-added` | `version-bump` | -| `event:session/title.ignorable` | `optional-property-added` | `version-bump` | -| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` | -| `event:session/title-llm-request.ignorable` | `optional-property-added` | `version-bump` | -| `event:step/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:step/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:subagent/descriptor.ignorable` | `optional-property-added` | `version-bump` | -| `event:todo/write.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool-workflow/agent-end` | `root-added` | `same-version` | -| `event:tool-workflow/agent-start` | `root-added` | `same-version` | -| `event:tool-workflow/run-end` | `root-added` | `same-version` | -| `event:tool-workflow/run-start` | `root-added` | `same-version` | -| `event:tool/call.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/result.ignorable` | `optional-property-added` | `version-bump` | -| `event:turn/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:turn/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:user/message.data.source` | `union-variants-changed` | `version-bump` | -| `event:user/message.ignorable` | `optional-property-added` | `version-bump` | -| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `version-bump` | +| `SessionEventEnvelope` | `union-variants-changed` | `review-required` | +| `event:agent-preset/selected.ignorable` | `optional-property-added` | `review-required` | +| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` | +| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/asked.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/decided.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/policy.ignorable` | `optional-property-added` | `review-required` | +| `event:assistant/chunk.ignorable` | `optional-property-added` | `review-required` | +| `event:assistant/message.ignorable` | `optional-property-added` | `review-required` | +| `event:command/done.ignorable` | `optional-property-added` | `review-required` | +| `event:command/run.ignorable` | `optional-property-added` | `review-required` | +| `event:compact/end.ignorable` | `optional-property-added` | `review-required` | +| `event:compact/prune.ignorable` | `optional-property-added` | `review-required` | +| `event:compact/start.ignorable` | `optional-property-added` | `review-required` | +| `event:compact/summary.ignorable` | `optional-property-added` | `review-required` | +| `event:feedback/record.ignorable` | `optional-property-added` | `review-required` | +| `event:goal/change.ignorable` | `optional-property-added` | `review-required` | +| `event:hook/invoked.ignorable` | `optional-property-added` | `review-required` | +| `event:hook/result.ignorable` | `optional-property-added` | `review-required` | +| `event:llm/retry.ignorable` | `optional-property-added` | `review-required` | +| `event:llm/retry-started.ignorable` | `optional-property-added` | `review-required` | +| `event:permission/preset.ignorable` | `optional-property-added` | `review-required` | +| `event:plan/mode.ignorable` | `optional-property-added` | `review-required` | +| `event:request/context.ignorable` | `optional-property-added` | `review-required` | +| `event:request/header.ignorable` | `optional-property-added` | `review-required` | +| `event:sandbox/mode.ignorable` | `optional-property-added` | `review-required` | +| `event:schedule/change` | `root-added` | `not-required` | +| `event:session/end-seed.ignorable` | `optional-property-added` | `review-required` | +| `event:session/title.ignorable` | `optional-property-added` | `review-required` | +| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` | +| `event:session/title-llm-request.ignorable` | `optional-property-added` | `review-required` | +| `event:step/end.ignorable` | `optional-property-added` | `review-required` | +| `event:step/start.ignorable` | `optional-property-added` | `review-required` | +| `event:subagent/descriptor.ignorable` | `optional-property-added` | `review-required` | +| `event:todo/write.ignorable` | `optional-property-added` | `review-required` | +| `event:tool-workflow/agent-end` | `root-added` | `not-required` | +| `event:tool-workflow/agent-start` | `root-added` | `not-required` | +| `event:tool-workflow/run-end` | `root-added` | `not-required` | +| `event:tool-workflow/run-start` | `root-added` | `not-required` | +| `event:tool/call.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/result.ignorable` | `optional-property-added` | `review-required` | +| `event:turn/end.ignorable` | `optional-property-added` | `review-required` | +| `event:turn/start.ignorable` | `optional-property-added` | `review-required` | +| `event:user/message.data.source` | `union-variants-changed` | `review-required` | +| `event:user/message.ignorable` | `optional-property-added` | `review-required` | +| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.i18n.yaml index 9a336ac41d..6e491600da 100644 --- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-0-1-rc-3/structural-changes: - en: b4ded7b41999584d - zh: 49c3f355a5559cbd + en: 0fd2841ea23b8cd6 + zh: 1138a25ac478eaa8 /persistence-release-dsh-v0-0-1-rc-3/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.md b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.md index 7201966f5e..fcede4e7bd 100644 --- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.md +++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.md @@ -92,22 +92,22 @@ changes: -Detected 12 changed roots and 12 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 12 changed roots and 12 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `event:agent/inbox/spliced.data.inserted[].source.kind` | `type-changed` | `version-bump` | -| `event:compact/end` | `root-removed` | `version-bump` | -| `event:compact/prune` | `root-removed` | `version-bump` | -| `event:compact/start` | `root-removed` | `version-bump` | -| `event:compact/summary` | `root-removed` | `version-bump` | -| `event:compaction/end` | `root-added` | `same-version` | -| `event:compaction/prune` | `root-added` | `same-version` | -| `event:compaction/start` | `root-added` | `same-version` | -| `event:compaction/summary` | `root-added` | `same-version` | -| `event:hook/invoked.data.dialect` | `type-changed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].source.kind` | `type-changed` | `version-bump` | -| `event:user/message.data.source.kind` | `type-changed` | `version-bump` | +| `event:agent/inbox/spliced.data.inserted[].source.kind` | `type-changed` | `review-required` | +| `event:compact/end` | `root-removed` | `review-required` | +| `event:compact/prune` | `root-removed` | `review-required` | +| `event:compact/start` | `root-removed` | `review-required` | +| `event:compact/summary` | `root-removed` | `review-required` | +| `event:compaction/end` | `root-added` | `not-required` | +| `event:compaction/prune` | `root-added` | `not-required` | +| `event:compaction/start` | `root-added` | `not-required` | +| `event:compaction/summary` | `root-added` | `not-required` | +| `event:hook/invoked.data.dialect` | `type-changed` | `review-required` | +| `event:session/title-llm-request.data.messages[].source.kind` | `type-changed` | `review-required` | +| `event:user/message.data.source.kind` | `type-changed` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.zh.md b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.zh.md index a59d39a86f..3ece24837e 100644 --- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.zh.md @@ -92,22 +92,22 @@ changes: -检测到 12 个根类型变化、12 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 12 个根类型变化、12 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `event:agent/inbox/spliced.data.inserted[].source.kind` | `type-changed` | `version-bump` | -| `event:compact/end` | `root-removed` | `version-bump` | -| `event:compact/prune` | `root-removed` | `version-bump` | -| `event:compact/start` | `root-removed` | `version-bump` | -| `event:compact/summary` | `root-removed` | `version-bump` | -| `event:compaction/end` | `root-added` | `same-version` | -| `event:compaction/prune` | `root-added` | `same-version` | -| `event:compaction/start` | `root-added` | `same-version` | -| `event:compaction/summary` | `root-added` | `same-version` | -| `event:hook/invoked.data.dialect` | `type-changed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].source.kind` | `type-changed` | `version-bump` | -| `event:user/message.data.source.kind` | `type-changed` | `version-bump` | +| `event:agent/inbox/spliced.data.inserted[].source.kind` | `type-changed` | `review-required` | +| `event:compact/end` | `root-removed` | `review-required` | +| `event:compact/prune` | `root-removed` | `review-required` | +| `event:compact/start` | `root-removed` | `review-required` | +| `event:compact/summary` | `root-removed` | `review-required` | +| `event:compaction/end` | `root-added` | `not-required` | +| `event:compaction/prune` | `root-added` | `not-required` | +| `event:compaction/start` | `root-added` | `not-required` | +| `event:compaction/summary` | `root-added` | `not-required` | +| `event:hook/invoked.data.dialect` | `type-changed` | `review-required` | +| `event:session/title-llm-request.data.messages[].source.kind` | `type-changed` | `review-required` | +| `event:user/message.data.source.kind` | `type-changed` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.i18n.yaml index 0efe3d557b..ca3647aa67 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-0-rc-7/structural-changes: - en: 5178d26722926711 - zh: bfe39111dd34ff48 + en: 1a08248deb5e841c + zh: b5dd3c2ac161a100 /persistence-release-dsh-v0-1-0-rc-7/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.md b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.md index 6427da5c46..4c30b5408f 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.md +++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.md @@ -59,11 +59,11 @@ changes: -Detected 1 changed root and 1 structural difference. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 1 changed root and 1 structural difference. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `event:assistant/chunk.data.chunk.replayState` | `type-changed` | `version-bump` | +| `event:assistant/chunk.data.chunk.replayState` | `type-changed` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.zh.md b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.zh.md index 91ae3f1061..379e11e6ff 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.zh.md @@ -59,11 +59,11 @@ changes: -检测到 1 个根类型变化、1 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 1 个根类型变化、1 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `event:assistant/chunk.data.chunk.replayState` | `type-changed` | `version-bump` | +| `event:assistant/chunk.data.chunk.replayState` | `type-changed` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.i18n.yaml index d2012c1958..f55fe23514 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-0-rc-8/structural-changes: - en: 6c7808df4fdcbf70 - zh: 8b6d83a8adec7f51 + en: 1909dc1b62eef347 + zh: 010e53d396fc675c /persistence-release-dsh-v0-1-0-rc-8/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.md b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.md index c52f050b82..83170d8295 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.md +++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.md @@ -80,18 +80,18 @@ changes: -Detected 8 changed roots and 8 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 8 changed roots and 8 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` | -| `event:assistant/message.data.interrupted` | `optional-property-added` | `same-version` | -| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` | -| `event:team/member` | `root-added` | `same-version` | -| `event:team/message/delivered` | `root-added` | `same-version` | -| `event:team/message/queued` | `root-added` | `same-version` | -| `event:team/task` | `root-added` | `same-version` | -| `event:user/message.data.source` | `union-variants-changed` | `version-bump` | +| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` | +| `event:assistant/message.data.interrupted` | `optional-property-added` | `not-required` | +| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` | +| `event:team/member` | `root-added` | `not-required` | +| `event:team/message/delivered` | `root-added` | `not-required` | +| `event:team/message/queued` | `root-added` | `not-required` | +| `event:team/task` | `root-added` | `not-required` | +| `event:user/message.data.source` | `union-variants-changed` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.zh.md b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.zh.md index b4fd283ede..a18e26f5fd 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.zh.md @@ -80,18 +80,18 @@ changes: -检测到 8 个根类型变化、8 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 8 个根类型变化、8 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` | -| `event:assistant/message.data.interrupted` | `optional-property-added` | `same-version` | -| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` | -| `event:team/member` | `root-added` | `same-version` | -| `event:team/message/delivered` | `root-added` | `same-version` | -| `event:team/message/queued` | `root-added` | `same-version` | -| `event:team/task` | `root-added` | `same-version` | -| `event:user/message.data.source` | `union-variants-changed` | `version-bump` | +| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` | +| `event:assistant/message.data.interrupted` | `optional-property-added` | `not-required` | +| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` | +| `event:team/member` | `root-added` | `not-required` | +| `event:team/message/delivered` | `root-added` | `not-required` | +| `event:team/message/queued` | `root-added` | `not-required` | +| `event:team/task` | `root-added` | `not-required` | +| `event:user/message.data.source` | `union-variants-changed` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.i18n.yaml index c5721338e9..0a07a76011 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-1-rc-1/structural-changes: - en: 0be3fa84192c242d - zh: 59dc45dd8e7f7594 + en: 69516d329a54a014 + zh: 16a3c770a9ba3217 /persistence-release-dsh-v0-1-1-rc-1/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.md b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.md index 6b598f1c61..23febf967f 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.md +++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.md @@ -59,11 +59,11 @@ changes: -Detected 1 changed root and 1 structural difference. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 1 changed root and 1 structural difference. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `event:permission/preset.data.origin` | `optional-property-added` | `same-version` | +| `event:permission/preset.data.origin` | `optional-property-added` | `not-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.zh.md b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.zh.md index eca10c509e..2220cf203a 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.zh.md @@ -59,11 +59,11 @@ changes: -检测到 1 个根类型变化、1 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 1 个根类型变化、1 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `event:permission/preset.data.origin` | `optional-property-added` | `same-version` | +| `event:permission/preset.data.origin` | `optional-property-added` | `not-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.i18n.yaml index f0259c93e1..54355badb7 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-1-rc-2/structural-changes: - en: c03cbb0f4bfb5d83 - zh: c23900507d4111aa + en: fedf3524c654f8b3 + zh: 4df22b00a3981e60 /persistence-release-dsh-v0-1-1-rc-2/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.md b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.md index 41cc4f0a39..694393a876 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.md +++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.md @@ -86,21 +86,21 @@ changes: -Detected 10 changed roots and 11 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 10 changed roots and 11 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `event:agent/inbox/spliced.data.inserted[].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:assistant/chunk.data.chunk.block.attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:assistant/message.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:compaction/summary.data.rawOutput[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:compaction/summary.data.summary[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:permission/preset.data.origin` | `property-removed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:team/message/queued.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:tool/code-dispatch.data.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:tool/result.data.message.content[0].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:user/message.data.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | +| `event:agent/inbox/spliced.data.inserted[].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:assistant/chunk.data.chunk.block.attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:assistant/message.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:compaction/summary.data.rawOutput[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:compaction/summary.data.summary[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:permission/preset.data.origin` | `property-removed` | `review-required` | +| `event:session/title-llm-request.data.messages[].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:team/message/queued.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:tool/code-dispatch.data.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:tool/result.data.message.content[0].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:user/message.data.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.zh.md b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.zh.md index 5ab22efd5d..7fa7ba008f 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.zh.md @@ -86,21 +86,21 @@ changes: -检测到 10 个根类型变化、11 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 10 个根类型变化、11 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `event:agent/inbox/spliced.data.inserted[].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:assistant/chunk.data.chunk.block.attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:assistant/message.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:compaction/summary.data.rawOutput[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:compaction/summary.data.summary[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:permission/preset.data.origin` | `property-removed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:team/message/queued.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:tool/code-dispatch.data.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:tool/result.data.message.content[0].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | -| `event:user/message.data.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` | +| `event:agent/inbox/spliced.data.inserted[].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:assistant/chunk.data.chunk.block.attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:assistant/message.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:compaction/summary.data.rawOutput[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:compaction/summary.data.summary[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:permission/preset.data.origin` | `property-removed` | `review-required` | +| `event:session/title-llm-request.data.messages[].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:team/message/queued.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:tool/code-dispatch.data.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:tool/result.data.message.content[0].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | +| `event:user/message.data.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.i18n.yaml index 8d25d441e9..b5891761cd 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-2-alpha-1/structural-changes: - en: f8e5907da99d32b9 - zh: dcd8dbc9660a20b6 + en: ebe16d1afafb4cbc + zh: d80430fa60b01c29 /persistence-release-dsh-v0-1-2-alpha-1/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.md index 3ed00447e4..5dfbc59f5f 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.md +++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.md @@ -212,71 +212,71 @@ changes: -Detected 52 changed roots and 61 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 52 changed roots and 61 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` | -| `event:agent-preset/selected.ignorable` | `property-removed` | `version-bump` | -| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` | -| `event:agent/inbox/spliced.ignorable` | `property-removed` | `version-bump` | -| `event:approval/asked.ignorable` | `property-removed` | `version-bump` | -| `event:approval/decided.ignorable` | `property-removed` | `version-bump` | -| `event:approval/policy.ignorable` | `property-removed` | `version-bump` | -| `event:assistant/chunk.data.chunk.usage.totalTokens` | `optional-property-added` | `same-version` | -| `event:assistant/chunk.ignorable` | `property-removed` | `version-bump` | -| `event:assistant/message.data.usage.totalTokens` | `optional-property-added` | `same-version` | -| `event:assistant/message.ignorable` | `property-removed` | `version-bump` | -| `event:command/done.ignorable` | `property-removed` | `version-bump` | -| `event:command/run.ignorable` | `property-removed` | `version-bump` | -| `event:compaction/end.ignorable` | `property-removed` | `version-bump` | -| `event:compaction/prune.ignorable` | `property-removed` | `version-bump` | -| `event:compaction/start.ignorable` | `property-removed` | `version-bump` | -| `event:compaction/summary.data.usage.totalTokens` | `optional-property-added` | `same-version` | -| `event:compaction/summary.ignorable` | `property-removed` | `version-bump` | -| `event:feedback/record.ignorable` | `property-removed` | `version-bump` | -| `event:goal/change.ignorable` | `property-removed` | `version-bump` | -| `event:hook/invoked.ignorable` | `property-removed` | `version-bump` | -| `event:hook/result.ignorable` | `property-removed` | `version-bump` | -| `event:llm/retry.ignorable` | `property-removed` | `version-bump` | -| `event:llm/retry-started.ignorable` | `property-removed` | `version-bump` | -| `event:model/selection` | `root-added` | `same-version` | -| `event:permission/preset.ignorable` | `property-removed` | `version-bump` | -| `event:plan/mode.ignorable` | `property-removed` | `version-bump` | -| `event:request/context.ignorable` | `property-removed` | `version-bump` | -| `event:request/header.data.reason` | `union-variants-changed` | `version-bump` | -| `event:request/header.data.startsSeries` | `optional-property-added` | `same-version` | -| `event:request/header.ignorable` | `property-removed` | `version-bump` | -| `event:sandbox/mode.ignorable` | `property-removed` | `version-bump` | -| `event:schedule/change.ignorable` | `property-removed` | `version-bump` | -| `event:session-log-deepseek/delivery-accepted` | `root-added` | `same-version` | -| `event:session/end-seed.ignorable` | `property-removed` | `version-bump` | -| `event:session/title.ignorable` | `property-removed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` | -| `event:session/title-llm-request.ignorable` | `property-removed` | `version-bump` | -| `event:step/end.ignorable` | `property-removed` | `version-bump` | -| `event:step/start.ignorable` | `property-removed` | `version-bump` | -| `event:subagent/descriptor.data.agentReasoningEffort` | `optional-property-added` | `same-version` | -| `event:subagent/descriptor.ignorable` | `property-removed` | `version-bump` | -| `event:subagent/model-selection-policy` | `root-added` | `same-version` | -| `event:team/member.ignorable` | `property-removed` | `version-bump` | -| `event:team/message/delivered.ignorable` | `property-removed` | `version-bump` | -| `event:team/message/queued.ignorable` | `property-removed` | `version-bump` | -| `event:team/task.ignorable` | `property-removed` | `version-bump` | -| `event:todo/write.ignorable` | `property-removed` | `version-bump` | -| `event:tool-workflow/agent-end.ignorable` | `property-removed` | `version-bump` | -| `event:tool-workflow/agent-start.ignorable` | `property-removed` | `version-bump` | -| `event:tool-workflow/run-end.ignorable` | `property-removed` | `version-bump` | -| `event:tool-workflow/run-start.ignorable` | `property-removed` | `version-bump` | -| `event:tool/call.ignorable` | `property-removed` | `version-bump` | -| `event:tool/code-dispatch.ignorable` | `property-removed` | `version-bump` | -| `event:tool/code-dispatch-start.ignorable` | `property-removed` | `version-bump` | -| `event:tool/result.ignorable` | `property-removed` | `version-bump` | -| `event:turn/end.ignorable` | `property-removed` | `version-bump` | -| `event:turn/start.ignorable` | `property-removed` | `version-bump` | -| `event:user/message.data.source` | `union-variants-changed` | `version-bump` | -| `event:user/message.ignorable` | `property-removed` | `version-bump` | -| `event:web/deepseek-search-llm-request.ignorable` | `property-removed` | `version-bump` | +| `SessionEventEnvelope` | `union-variants-changed` | `review-required` | +| `event:agent-preset/selected.ignorable` | `property-removed` | `review-required` | +| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` | +| `event:agent/inbox/spliced.ignorable` | `property-removed` | `review-required` | +| `event:approval/asked.ignorable` | `property-removed` | `review-required` | +| `event:approval/decided.ignorable` | `property-removed` | `review-required` | +| `event:approval/policy.ignorable` | `property-removed` | `review-required` | +| `event:assistant/chunk.data.chunk.usage.totalTokens` | `optional-property-added` | `not-required` | +| `event:assistant/chunk.ignorable` | `property-removed` | `review-required` | +| `event:assistant/message.data.usage.totalTokens` | `optional-property-added` | `not-required` | +| `event:assistant/message.ignorable` | `property-removed` | `review-required` | +| `event:command/done.ignorable` | `property-removed` | `review-required` | +| `event:command/run.ignorable` | `property-removed` | `review-required` | +| `event:compaction/end.ignorable` | `property-removed` | `review-required` | +| `event:compaction/prune.ignorable` | `property-removed` | `review-required` | +| `event:compaction/start.ignorable` | `property-removed` | `review-required` | +| `event:compaction/summary.data.usage.totalTokens` | `optional-property-added` | `not-required` | +| `event:compaction/summary.ignorable` | `property-removed` | `review-required` | +| `event:feedback/record.ignorable` | `property-removed` | `review-required` | +| `event:goal/change.ignorable` | `property-removed` | `review-required` | +| `event:hook/invoked.ignorable` | `property-removed` | `review-required` | +| `event:hook/result.ignorable` | `property-removed` | `review-required` | +| `event:llm/retry.ignorable` | `property-removed` | `review-required` | +| `event:llm/retry-started.ignorable` | `property-removed` | `review-required` | +| `event:model/selection` | `root-added` | `not-required` | +| `event:permission/preset.ignorable` | `property-removed` | `review-required` | +| `event:plan/mode.ignorable` | `property-removed` | `review-required` | +| `event:request/context.ignorable` | `property-removed` | `review-required` | +| `event:request/header.data.reason` | `union-variants-changed` | `review-required` | +| `event:request/header.data.startsSeries` | `optional-property-added` | `not-required` | +| `event:request/header.ignorable` | `property-removed` | `review-required` | +| `event:sandbox/mode.ignorable` | `property-removed` | `review-required` | +| `event:schedule/change.ignorable` | `property-removed` | `review-required` | +| `event:session-log-deepseek/delivery-accepted` | `root-added` | `not-required` | +| `event:session/end-seed.ignorable` | `property-removed` | `review-required` | +| `event:session/title.ignorable` | `property-removed` | `review-required` | +| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` | +| `event:session/title-llm-request.ignorable` | `property-removed` | `review-required` | +| `event:step/end.ignorable` | `property-removed` | `review-required` | +| `event:step/start.ignorable` | `property-removed` | `review-required` | +| `event:subagent/descriptor.data.agentReasoningEffort` | `optional-property-added` | `not-required` | +| `event:subagent/descriptor.ignorable` | `property-removed` | `review-required` | +| `event:subagent/model-selection-policy` | `root-added` | `not-required` | +| `event:team/member.ignorable` | `property-removed` | `review-required` | +| `event:team/message/delivered.ignorable` | `property-removed` | `review-required` | +| `event:team/message/queued.ignorable` | `property-removed` | `review-required` | +| `event:team/task.ignorable` | `property-removed` | `review-required` | +| `event:todo/write.ignorable` | `property-removed` | `review-required` | +| `event:tool-workflow/agent-end.ignorable` | `property-removed` | `review-required` | +| `event:tool-workflow/agent-start.ignorable` | `property-removed` | `review-required` | +| `event:tool-workflow/run-end.ignorable` | `property-removed` | `review-required` | +| `event:tool-workflow/run-start.ignorable` | `property-removed` | `review-required` | +| `event:tool/call.ignorable` | `property-removed` | `review-required` | +| `event:tool/code-dispatch.ignorable` | `property-removed` | `review-required` | +| `event:tool/code-dispatch-start.ignorable` | `property-removed` | `review-required` | +| `event:tool/result.ignorable` | `property-removed` | `review-required` | +| `event:turn/end.ignorable` | `property-removed` | `review-required` | +| `event:turn/start.ignorable` | `property-removed` | `review-required` | +| `event:user/message.data.source` | `union-variants-changed` | `review-required` | +| `event:user/message.ignorable` | `property-removed` | `review-required` | +| `event:web/deepseek-search-llm-request.ignorable` | `property-removed` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.zh.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.zh.md index 09a6bf3df1..1c5abc026b 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.zh.md @@ -212,71 +212,71 @@ changes: -检测到 52 个根类型变化、61 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 52 个根类型变化、61 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` | -| `event:agent-preset/selected.ignorable` | `property-removed` | `version-bump` | -| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` | -| `event:agent/inbox/spliced.ignorable` | `property-removed` | `version-bump` | -| `event:approval/asked.ignorable` | `property-removed` | `version-bump` | -| `event:approval/decided.ignorable` | `property-removed` | `version-bump` | -| `event:approval/policy.ignorable` | `property-removed` | `version-bump` | -| `event:assistant/chunk.data.chunk.usage.totalTokens` | `optional-property-added` | `same-version` | -| `event:assistant/chunk.ignorable` | `property-removed` | `version-bump` | -| `event:assistant/message.data.usage.totalTokens` | `optional-property-added` | `same-version` | -| `event:assistant/message.ignorable` | `property-removed` | `version-bump` | -| `event:command/done.ignorable` | `property-removed` | `version-bump` | -| `event:command/run.ignorable` | `property-removed` | `version-bump` | -| `event:compaction/end.ignorable` | `property-removed` | `version-bump` | -| `event:compaction/prune.ignorable` | `property-removed` | `version-bump` | -| `event:compaction/start.ignorable` | `property-removed` | `version-bump` | -| `event:compaction/summary.data.usage.totalTokens` | `optional-property-added` | `same-version` | -| `event:compaction/summary.ignorable` | `property-removed` | `version-bump` | -| `event:feedback/record.ignorable` | `property-removed` | `version-bump` | -| `event:goal/change.ignorable` | `property-removed` | `version-bump` | -| `event:hook/invoked.ignorable` | `property-removed` | `version-bump` | -| `event:hook/result.ignorable` | `property-removed` | `version-bump` | -| `event:llm/retry.ignorable` | `property-removed` | `version-bump` | -| `event:llm/retry-started.ignorable` | `property-removed` | `version-bump` | -| `event:model/selection` | `root-added` | `same-version` | -| `event:permission/preset.ignorable` | `property-removed` | `version-bump` | -| `event:plan/mode.ignorable` | `property-removed` | `version-bump` | -| `event:request/context.ignorable` | `property-removed` | `version-bump` | -| `event:request/header.data.reason` | `union-variants-changed` | `version-bump` | -| `event:request/header.data.startsSeries` | `optional-property-added` | `same-version` | -| `event:request/header.ignorable` | `property-removed` | `version-bump` | -| `event:sandbox/mode.ignorable` | `property-removed` | `version-bump` | -| `event:schedule/change.ignorable` | `property-removed` | `version-bump` | -| `event:session-log-deepseek/delivery-accepted` | `root-added` | `same-version` | -| `event:session/end-seed.ignorable` | `property-removed` | `version-bump` | -| `event:session/title.ignorable` | `property-removed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` | -| `event:session/title-llm-request.ignorable` | `property-removed` | `version-bump` | -| `event:step/end.ignorable` | `property-removed` | `version-bump` | -| `event:step/start.ignorable` | `property-removed` | `version-bump` | -| `event:subagent/descriptor.data.agentReasoningEffort` | `optional-property-added` | `same-version` | -| `event:subagent/descriptor.ignorable` | `property-removed` | `version-bump` | -| `event:subagent/model-selection-policy` | `root-added` | `same-version` | -| `event:team/member.ignorable` | `property-removed` | `version-bump` | -| `event:team/message/delivered.ignorable` | `property-removed` | `version-bump` | -| `event:team/message/queued.ignorable` | `property-removed` | `version-bump` | -| `event:team/task.ignorable` | `property-removed` | `version-bump` | -| `event:todo/write.ignorable` | `property-removed` | `version-bump` | -| `event:tool-workflow/agent-end.ignorable` | `property-removed` | `version-bump` | -| `event:tool-workflow/agent-start.ignorable` | `property-removed` | `version-bump` | -| `event:tool-workflow/run-end.ignorable` | `property-removed` | `version-bump` | -| `event:tool-workflow/run-start.ignorable` | `property-removed` | `version-bump` | -| `event:tool/call.ignorable` | `property-removed` | `version-bump` | -| `event:tool/code-dispatch.ignorable` | `property-removed` | `version-bump` | -| `event:tool/code-dispatch-start.ignorable` | `property-removed` | `version-bump` | -| `event:tool/result.ignorable` | `property-removed` | `version-bump` | -| `event:turn/end.ignorable` | `property-removed` | `version-bump` | -| `event:turn/start.ignorable` | `property-removed` | `version-bump` | -| `event:user/message.data.source` | `union-variants-changed` | `version-bump` | -| `event:user/message.ignorable` | `property-removed` | `version-bump` | -| `event:web/deepseek-search-llm-request.ignorable` | `property-removed` | `version-bump` | +| `SessionEventEnvelope` | `union-variants-changed` | `review-required` | +| `event:agent-preset/selected.ignorable` | `property-removed` | `review-required` | +| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` | +| `event:agent/inbox/spliced.ignorable` | `property-removed` | `review-required` | +| `event:approval/asked.ignorable` | `property-removed` | `review-required` | +| `event:approval/decided.ignorable` | `property-removed` | `review-required` | +| `event:approval/policy.ignorable` | `property-removed` | `review-required` | +| `event:assistant/chunk.data.chunk.usage.totalTokens` | `optional-property-added` | `not-required` | +| `event:assistant/chunk.ignorable` | `property-removed` | `review-required` | +| `event:assistant/message.data.usage.totalTokens` | `optional-property-added` | `not-required` | +| `event:assistant/message.ignorable` | `property-removed` | `review-required` | +| `event:command/done.ignorable` | `property-removed` | `review-required` | +| `event:command/run.ignorable` | `property-removed` | `review-required` | +| `event:compaction/end.ignorable` | `property-removed` | `review-required` | +| `event:compaction/prune.ignorable` | `property-removed` | `review-required` | +| `event:compaction/start.ignorable` | `property-removed` | `review-required` | +| `event:compaction/summary.data.usage.totalTokens` | `optional-property-added` | `not-required` | +| `event:compaction/summary.ignorable` | `property-removed` | `review-required` | +| `event:feedback/record.ignorable` | `property-removed` | `review-required` | +| `event:goal/change.ignorable` | `property-removed` | `review-required` | +| `event:hook/invoked.ignorable` | `property-removed` | `review-required` | +| `event:hook/result.ignorable` | `property-removed` | `review-required` | +| `event:llm/retry.ignorable` | `property-removed` | `review-required` | +| `event:llm/retry-started.ignorable` | `property-removed` | `review-required` | +| `event:model/selection` | `root-added` | `not-required` | +| `event:permission/preset.ignorable` | `property-removed` | `review-required` | +| `event:plan/mode.ignorable` | `property-removed` | `review-required` | +| `event:request/context.ignorable` | `property-removed` | `review-required` | +| `event:request/header.data.reason` | `union-variants-changed` | `review-required` | +| `event:request/header.data.startsSeries` | `optional-property-added` | `not-required` | +| `event:request/header.ignorable` | `property-removed` | `review-required` | +| `event:sandbox/mode.ignorable` | `property-removed` | `review-required` | +| `event:schedule/change.ignorable` | `property-removed` | `review-required` | +| `event:session-log-deepseek/delivery-accepted` | `root-added` | `not-required` | +| `event:session/end-seed.ignorable` | `property-removed` | `review-required` | +| `event:session/title.ignorable` | `property-removed` | `review-required` | +| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` | +| `event:session/title-llm-request.ignorable` | `property-removed` | `review-required` | +| `event:step/end.ignorable` | `property-removed` | `review-required` | +| `event:step/start.ignorable` | `property-removed` | `review-required` | +| `event:subagent/descriptor.data.agentReasoningEffort` | `optional-property-added` | `not-required` | +| `event:subagent/descriptor.ignorable` | `property-removed` | `review-required` | +| `event:subagent/model-selection-policy` | `root-added` | `not-required` | +| `event:team/member.ignorable` | `property-removed` | `review-required` | +| `event:team/message/delivered.ignorable` | `property-removed` | `review-required` | +| `event:team/message/queued.ignorable` | `property-removed` | `review-required` | +| `event:team/task.ignorable` | `property-removed` | `review-required` | +| `event:todo/write.ignorable` | `property-removed` | `review-required` | +| `event:tool-workflow/agent-end.ignorable` | `property-removed` | `review-required` | +| `event:tool-workflow/agent-start.ignorable` | `property-removed` | `review-required` | +| `event:tool-workflow/run-end.ignorable` | `property-removed` | `review-required` | +| `event:tool-workflow/run-start.ignorable` | `property-removed` | `review-required` | +| `event:tool/call.ignorable` | `property-removed` | `review-required` | +| `event:tool/code-dispatch.ignorable` | `property-removed` | `review-required` | +| `event:tool/code-dispatch-start.ignorable` | `property-removed` | `review-required` | +| `event:tool/result.ignorable` | `property-removed` | `review-required` | +| `event:turn/end.ignorable` | `property-removed` | `review-required` | +| `event:turn/start.ignorable` | `property-removed` | `review-required` | +| `event:user/message.data.source` | `union-variants-changed` | `review-required` | +| `event:user/message.ignorable` | `property-removed` | `review-required` | +| `event:web/deepseek-search-llm-request.ignorable` | `property-removed` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.i18n.yaml index 7bc9636a7a..da6fdae068 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-2-alpha-2/structural-changes: - en: 28cb9fbbfbc761af - zh: f3345b2dc10920b1 + en: aeca9db54d4a5ea4 + zh: fa2e354c039b8381 /persistence-release-dsh-v0-1-2-alpha-2/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.md index 8d4830f4b4..cfd6eb4225 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.md +++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.md @@ -212,62 +212,62 @@ changes: -Detected 52 changed roots and 52 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 52 changed roots and 52 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` | -| `event:agent-preset/selected.ignorable` | `optional-property-added` | `version-bump` | -| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/asked.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/decided.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/policy.ignorable` | `optional-property-added` | `version-bump` | -| `event:assistant/chunk.ignorable` | `optional-property-added` | `version-bump` | -| `event:assistant/message.ignorable` | `optional-property-added` | `version-bump` | -| `event:command/done.ignorable` | `optional-property-added` | `version-bump` | -| `event:command/run.ignorable` | `optional-property-added` | `version-bump` | -| `event:compaction/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:compaction/prune.ignorable` | `optional-property-added` | `version-bump` | -| `event:compaction/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:compaction/summary.ignorable` | `optional-property-added` | `version-bump` | -| `event:feedback/record.ignorable` | `optional-property-added` | `version-bump` | -| `event:goal/change.ignorable` | `optional-property-added` | `version-bump` | -| `event:hook/invoked.ignorable` | `optional-property-added` | `version-bump` | -| `event:hook/result.ignorable` | `optional-property-added` | `version-bump` | -| `event:llm/retry.ignorable` | `optional-property-added` | `version-bump` | -| `event:llm/retry-started.ignorable` | `optional-property-added` | `version-bump` | -| `event:model/selection.ignorable` | `optional-property-added` | `version-bump` | -| `event:permission/preset.ignorable` | `optional-property-added` | `version-bump` | -| `event:plan/mode.ignorable` | `optional-property-added` | `version-bump` | -| `event:request/context.ignorable` | `optional-property-added` | `version-bump` | -| `event:request/header.ignorable` | `optional-property-added` | `version-bump` | -| `event:sandbox/mode.ignorable` | `optional-property-added` | `version-bump` | -| `event:schedule/change.ignorable` | `optional-property-added` | `version-bump` | -| `event:session-log-deepseek/delivery-accepted.ignorable` | `optional-property-added` | `version-bump` | -| `event:session/end-seed.ignorable` | `optional-property-added` | `version-bump` | -| `event:session/title.ignorable` | `optional-property-added` | `version-bump` | -| `event:session/title-llm-request.ignorable` | `optional-property-added` | `version-bump` | -| `event:step/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:step/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:subagent/descriptor.ignorable` | `optional-property-added` | `version-bump` | -| `event:subagent/model-selection-policy.ignorable` | `optional-property-added` | `version-bump` | -| `event:team/member.ignorable` | `optional-property-added` | `version-bump` | -| `event:team/message/delivered.ignorable` | `optional-property-added` | `version-bump` | -| `event:team/message/queued.ignorable` | `optional-property-added` | `version-bump` | -| `event:team/task.ignorable` | `optional-property-added` | `version-bump` | -| `event:todo/write.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool-workflow/agent-end.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool-workflow/agent-start.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool-workflow/run-end.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool-workflow/run-start.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/call.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/result.ignorable` | `optional-property-added` | `version-bump` | -| `event:turn/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:turn/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:user/message.ignorable` | `optional-property-added` | `version-bump` | -| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `version-bump` | +| `SessionEventEnvelope` | `union-variants-changed` | `review-required` | +| `event:agent-preset/selected.ignorable` | `optional-property-added` | `review-required` | +| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/asked.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/decided.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/policy.ignorable` | `optional-property-added` | `review-required` | +| `event:assistant/chunk.ignorable` | `optional-property-added` | `review-required` | +| `event:assistant/message.ignorable` | `optional-property-added` | `review-required` | +| `event:command/done.ignorable` | `optional-property-added` | `review-required` | +| `event:command/run.ignorable` | `optional-property-added` | `review-required` | +| `event:compaction/end.ignorable` | `optional-property-added` | `review-required` | +| `event:compaction/prune.ignorable` | `optional-property-added` | `review-required` | +| `event:compaction/start.ignorable` | `optional-property-added` | `review-required` | +| `event:compaction/summary.ignorable` | `optional-property-added` | `review-required` | +| `event:feedback/record.ignorable` | `optional-property-added` | `review-required` | +| `event:goal/change.ignorable` | `optional-property-added` | `review-required` | +| `event:hook/invoked.ignorable` | `optional-property-added` | `review-required` | +| `event:hook/result.ignorable` | `optional-property-added` | `review-required` | +| `event:llm/retry.ignorable` | `optional-property-added` | `review-required` | +| `event:llm/retry-started.ignorable` | `optional-property-added` | `review-required` | +| `event:model/selection.ignorable` | `optional-property-added` | `review-required` | +| `event:permission/preset.ignorable` | `optional-property-added` | `review-required` | +| `event:plan/mode.ignorable` | `optional-property-added` | `review-required` | +| `event:request/context.ignorable` | `optional-property-added` | `review-required` | +| `event:request/header.ignorable` | `optional-property-added` | `review-required` | +| `event:sandbox/mode.ignorable` | `optional-property-added` | `review-required` | +| `event:schedule/change.ignorable` | `optional-property-added` | `review-required` | +| `event:session-log-deepseek/delivery-accepted.ignorable` | `optional-property-added` | `review-required` | +| `event:session/end-seed.ignorable` | `optional-property-added` | `review-required` | +| `event:session/title.ignorable` | `optional-property-added` | `review-required` | +| `event:session/title-llm-request.ignorable` | `optional-property-added` | `review-required` | +| `event:step/end.ignorable` | `optional-property-added` | `review-required` | +| `event:step/start.ignorable` | `optional-property-added` | `review-required` | +| `event:subagent/descriptor.ignorable` | `optional-property-added` | `review-required` | +| `event:subagent/model-selection-policy.ignorable` | `optional-property-added` | `review-required` | +| `event:team/member.ignorable` | `optional-property-added` | `review-required` | +| `event:team/message/delivered.ignorable` | `optional-property-added` | `review-required` | +| `event:team/message/queued.ignorable` | `optional-property-added` | `review-required` | +| `event:team/task.ignorable` | `optional-property-added` | `review-required` | +| `event:todo/write.ignorable` | `optional-property-added` | `review-required` | +| `event:tool-workflow/agent-end.ignorable` | `optional-property-added` | `review-required` | +| `event:tool-workflow/agent-start.ignorable` | `optional-property-added` | `review-required` | +| `event:tool-workflow/run-end.ignorable` | `optional-property-added` | `review-required` | +| `event:tool-workflow/run-start.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/call.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/result.ignorable` | `optional-property-added` | `review-required` | +| `event:turn/end.ignorable` | `optional-property-added` | `review-required` | +| `event:turn/start.ignorable` | `optional-property-added` | `review-required` | +| `event:user/message.ignorable` | `optional-property-added` | `review-required` | +| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.zh.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.zh.md index b68ea09841..4d6b39343c 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.zh.md @@ -212,62 +212,62 @@ changes: -检测到 52 个根类型变化、52 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 52 个根类型变化、52 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` | -| `event:agent-preset/selected.ignorable` | `optional-property-added` | `version-bump` | -| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/asked.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/decided.ignorable` | `optional-property-added` | `version-bump` | -| `event:approval/policy.ignorable` | `optional-property-added` | `version-bump` | -| `event:assistant/chunk.ignorable` | `optional-property-added` | `version-bump` | -| `event:assistant/message.ignorable` | `optional-property-added` | `version-bump` | -| `event:command/done.ignorable` | `optional-property-added` | `version-bump` | -| `event:command/run.ignorable` | `optional-property-added` | `version-bump` | -| `event:compaction/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:compaction/prune.ignorable` | `optional-property-added` | `version-bump` | -| `event:compaction/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:compaction/summary.ignorable` | `optional-property-added` | `version-bump` | -| `event:feedback/record.ignorable` | `optional-property-added` | `version-bump` | -| `event:goal/change.ignorable` | `optional-property-added` | `version-bump` | -| `event:hook/invoked.ignorable` | `optional-property-added` | `version-bump` | -| `event:hook/result.ignorable` | `optional-property-added` | `version-bump` | -| `event:llm/retry.ignorable` | `optional-property-added` | `version-bump` | -| `event:llm/retry-started.ignorable` | `optional-property-added` | `version-bump` | -| `event:model/selection.ignorable` | `optional-property-added` | `version-bump` | -| `event:permission/preset.ignorable` | `optional-property-added` | `version-bump` | -| `event:plan/mode.ignorable` | `optional-property-added` | `version-bump` | -| `event:request/context.ignorable` | `optional-property-added` | `version-bump` | -| `event:request/header.ignorable` | `optional-property-added` | `version-bump` | -| `event:sandbox/mode.ignorable` | `optional-property-added` | `version-bump` | -| `event:schedule/change.ignorable` | `optional-property-added` | `version-bump` | -| `event:session-log-deepseek/delivery-accepted.ignorable` | `optional-property-added` | `version-bump` | -| `event:session/end-seed.ignorable` | `optional-property-added` | `version-bump` | -| `event:session/title.ignorable` | `optional-property-added` | `version-bump` | -| `event:session/title-llm-request.ignorable` | `optional-property-added` | `version-bump` | -| `event:step/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:step/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:subagent/descriptor.ignorable` | `optional-property-added` | `version-bump` | -| `event:subagent/model-selection-policy.ignorable` | `optional-property-added` | `version-bump` | -| `event:team/member.ignorable` | `optional-property-added` | `version-bump` | -| `event:team/message/delivered.ignorable` | `optional-property-added` | `version-bump` | -| `event:team/message/queued.ignorable` | `optional-property-added` | `version-bump` | -| `event:team/task.ignorable` | `optional-property-added` | `version-bump` | -| `event:todo/write.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool-workflow/agent-end.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool-workflow/agent-start.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool-workflow/run-end.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool-workflow/run-start.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/call.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `version-bump` | -| `event:tool/result.ignorable` | `optional-property-added` | `version-bump` | -| `event:turn/end.ignorable` | `optional-property-added` | `version-bump` | -| `event:turn/start.ignorable` | `optional-property-added` | `version-bump` | -| `event:user/message.ignorable` | `optional-property-added` | `version-bump` | -| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `version-bump` | +| `SessionEventEnvelope` | `union-variants-changed` | `review-required` | +| `event:agent-preset/selected.ignorable` | `optional-property-added` | `review-required` | +| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/asked.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/decided.ignorable` | `optional-property-added` | `review-required` | +| `event:approval/policy.ignorable` | `optional-property-added` | `review-required` | +| `event:assistant/chunk.ignorable` | `optional-property-added` | `review-required` | +| `event:assistant/message.ignorable` | `optional-property-added` | `review-required` | +| `event:command/done.ignorable` | `optional-property-added` | `review-required` | +| `event:command/run.ignorable` | `optional-property-added` | `review-required` | +| `event:compaction/end.ignorable` | `optional-property-added` | `review-required` | +| `event:compaction/prune.ignorable` | `optional-property-added` | `review-required` | +| `event:compaction/start.ignorable` | `optional-property-added` | `review-required` | +| `event:compaction/summary.ignorable` | `optional-property-added` | `review-required` | +| `event:feedback/record.ignorable` | `optional-property-added` | `review-required` | +| `event:goal/change.ignorable` | `optional-property-added` | `review-required` | +| `event:hook/invoked.ignorable` | `optional-property-added` | `review-required` | +| `event:hook/result.ignorable` | `optional-property-added` | `review-required` | +| `event:llm/retry.ignorable` | `optional-property-added` | `review-required` | +| `event:llm/retry-started.ignorable` | `optional-property-added` | `review-required` | +| `event:model/selection.ignorable` | `optional-property-added` | `review-required` | +| `event:permission/preset.ignorable` | `optional-property-added` | `review-required` | +| `event:plan/mode.ignorable` | `optional-property-added` | `review-required` | +| `event:request/context.ignorable` | `optional-property-added` | `review-required` | +| `event:request/header.ignorable` | `optional-property-added` | `review-required` | +| `event:sandbox/mode.ignorable` | `optional-property-added` | `review-required` | +| `event:schedule/change.ignorable` | `optional-property-added` | `review-required` | +| `event:session-log-deepseek/delivery-accepted.ignorable` | `optional-property-added` | `review-required` | +| `event:session/end-seed.ignorable` | `optional-property-added` | `review-required` | +| `event:session/title.ignorable` | `optional-property-added` | `review-required` | +| `event:session/title-llm-request.ignorable` | `optional-property-added` | `review-required` | +| `event:step/end.ignorable` | `optional-property-added` | `review-required` | +| `event:step/start.ignorable` | `optional-property-added` | `review-required` | +| `event:subagent/descriptor.ignorable` | `optional-property-added` | `review-required` | +| `event:subagent/model-selection-policy.ignorable` | `optional-property-added` | `review-required` | +| `event:team/member.ignorable` | `optional-property-added` | `review-required` | +| `event:team/message/delivered.ignorable` | `optional-property-added` | `review-required` | +| `event:team/message/queued.ignorable` | `optional-property-added` | `review-required` | +| `event:team/task.ignorable` | `optional-property-added` | `review-required` | +| `event:todo/write.ignorable` | `optional-property-added` | `review-required` | +| `event:tool-workflow/agent-end.ignorable` | `optional-property-added` | `review-required` | +| `event:tool-workflow/agent-start.ignorable` | `optional-property-added` | `review-required` | +| `event:tool-workflow/run-end.ignorable` | `optional-property-added` | `review-required` | +| `event:tool-workflow/run-start.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/call.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `review-required` | +| `event:tool/result.ignorable` | `optional-property-added` | `review-required` | +| `event:turn/end.ignorable` | `optional-property-added` | `review-required` | +| `event:turn/start.ignorable` | `optional-property-added` | `review-required` | +| `event:user/message.ignorable` | `optional-property-added` | `review-required` | +| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.i18n.yaml index cea2d1c259..c1f9bddfbf 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-2-alpha-4/structural-changes: - en: 19dc565f0446afc5 - zh: 16309465db21bd8b + en: eec8e831ff143edc + zh: 253dcaaf41ecac6f /persistence-release-dsh-v0-1-2-alpha-4/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.md index 9a6be5072c..d83a5e1bba 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.md +++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.md @@ -68,15 +68,15 @@ changes: -Detected 4 changed roots and 5 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 4 changed roots and 5 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `SessionHeader.seedLength` | `property-removed` | `version-bump` | -| `SessionHeader.isSeeded` | `required-property-added` | `version-bump` | -| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` | -| `event:user/message.data.source` | `union-variants-changed` | `version-bump` | +| `SessionHeader.seedLength` | `property-removed` | `review-required` | +| `SessionHeader.isSeeded` | `required-property-added` | `review-required` | +| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` | +| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` | +| `event:user/message.data.source` | `union-variants-changed` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.zh.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.zh.md index a279aebe89..aedf30a137 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.zh.md @@ -68,15 +68,15 @@ changes: -检测到 4 个根类型变化、5 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 4 个根类型变化、5 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `SessionHeader.seedLength` | `property-removed` | `version-bump` | -| `SessionHeader.isSeeded` | `required-property-added` | `version-bump` | -| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` | -| `event:user/message.data.source` | `union-variants-changed` | `version-bump` | +| `SessionHeader.seedLength` | `property-removed` | `review-required` | +| `SessionHeader.isSeeded` | `required-property-added` | `review-required` | +| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` | +| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` | +| `event:user/message.data.source` | `union-variants-changed` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.i18n.yaml index 31145c4ef4..2e37ebb2c3 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-3-alpha-1/structural-changes: - en: 492c315af4d250aa - zh: 17a45e34aeb94791 + en: 746c20adf88325e1 + zh: e607c8b6e3dc8a3a /persistence-release-dsh-v0-1-3-alpha-1/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.md b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.md index 571718ea48..ef21066705 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.md +++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.md @@ -107,35 +107,35 @@ changes: -Detected 17 changed roots and 25 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 17 changed roots and 25 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `JsonlHeaderLine.seedLength` | `property-removed` | `version-bump` | -| `JsonlHeaderLine.isSeeded` | `required-property-added` | `version-bump` | -| `SessionHeader.version` | `type-changed` | `version-bump` | -| `event:agent/inbox/spliced.data.inserted[].content[]` | `union-variants-changed` | `version-bump` | -| `event:agent/inbox/spliced.data.inserted[].source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` | -| `event:assistant/attempt` | `root-added` | `same-version` | -| `event:assistant/chunk` | `root-removed` | `version-bump` | -| `event:assistant/message.data.message.content[]` | `union-variants-changed` | `version-bump` | -| `event:assistant/message.data.stream` | `required-property-added` | `version-bump` | -| `event:compaction/summary.data` | `union-variants-changed` | `version-bump` | -| `event:session-log-deepseek/delivery-accepted.data.sessionFormatVersion` | `optional-property-added` | `same-version` | -| `event:session/end-seed.data.inherited` | `optional-property-added` | `same-version` | -| `event:session/end-seed.data` | `index-signature-changed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].content[]` | `union-variants-changed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` | -| `event:team/member.data.version` | `type-changed` | `version-bump` | -| `event:team/message/delivered.data.version` | `type-changed` | `version-bump` | -| `event:team/message/queued.data.message.content[]` | `union-variants-changed` | `version-bump` | -| `event:team/message/queued.data.message.delivery` | `property-removed` | `version-bump` | -| `event:team/message/queued.data.version` | `type-changed` | `version-bump` | -| `event:team/task.data.version` | `type-changed` | `version-bump` | -| `event:tool/code-dispatch.data.content[]` | `union-variants-changed` | `version-bump` | -| `event:tool/result.data.message.content[0].content[]` | `union-variants-changed` | `version-bump` | -| `event:user/message.data.content[]` | `union-variants-changed` | `version-bump` | -| `event:user/message.data.source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` | +| `JsonlHeaderLine.seedLength` | `property-removed` | `review-required` | +| `JsonlHeaderLine.isSeeded` | `required-property-added` | `review-required` | +| `SessionHeader.version` | `type-changed` | `review-required` | +| `event:agent/inbox/spliced.data.inserted[].content[]` | `union-variants-changed` | `review-required` | +| `event:agent/inbox/spliced.data.inserted[].source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` | +| `event:assistant/attempt` | `root-added` | `not-required` | +| `event:assistant/chunk` | `root-removed` | `review-required` | +| `event:assistant/message.data.message.content[]` | `union-variants-changed` | `review-required` | +| `event:assistant/message.data.stream` | `required-property-added` | `review-required` | +| `event:compaction/summary.data` | `union-variants-changed` | `review-required` | +| `event:session-log-deepseek/delivery-accepted.data.sessionFormatVersion` | `optional-property-added` | `not-required` | +| `event:session/end-seed.data.inherited` | `optional-property-added` | `not-required` | +| `event:session/end-seed.data` | `index-signature-changed` | `review-required` | +| `event:session/title-llm-request.data.messages[].content[]` | `union-variants-changed` | `review-required` | +| `event:session/title-llm-request.data.messages[].source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` | +| `event:team/member.data.version` | `type-changed` | `review-required` | +| `event:team/message/delivered.data.version` | `type-changed` | `review-required` | +| `event:team/message/queued.data.message.content[]` | `union-variants-changed` | `review-required` | +| `event:team/message/queued.data.message.delivery` | `property-removed` | `review-required` | +| `event:team/message/queued.data.version` | `type-changed` | `review-required` | +| `event:team/task.data.version` | `type-changed` | `review-required` | +| `event:tool/code-dispatch.data.content[]` | `union-variants-changed` | `review-required` | +| `event:tool/result.data.message.content[0].content[]` | `union-variants-changed` | `review-required` | +| `event:user/message.data.content[]` | `union-variants-changed` | `review-required` | +| `event:user/message.data.source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.zh.md b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.zh.md index 980106910f..af0b35779e 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.zh.md @@ -107,35 +107,35 @@ changes: -检测到 17 个根类型变化、25 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 17 个根类型变化、25 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `JsonlHeaderLine.seedLength` | `property-removed` | `version-bump` | -| `JsonlHeaderLine.isSeeded` | `required-property-added` | `version-bump` | -| `SessionHeader.version` | `type-changed` | `version-bump` | -| `event:agent/inbox/spliced.data.inserted[].content[]` | `union-variants-changed` | `version-bump` | -| `event:agent/inbox/spliced.data.inserted[].source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` | -| `event:assistant/attempt` | `root-added` | `same-version` | -| `event:assistant/chunk` | `root-removed` | `version-bump` | -| `event:assistant/message.data.message.content[]` | `union-variants-changed` | `version-bump` | -| `event:assistant/message.data.stream` | `required-property-added` | `version-bump` | -| `event:compaction/summary.data` | `union-variants-changed` | `version-bump` | -| `event:session-log-deepseek/delivery-accepted.data.sessionFormatVersion` | `optional-property-added` | `same-version` | -| `event:session/end-seed.data.inherited` | `optional-property-added` | `same-version` | -| `event:session/end-seed.data` | `index-signature-changed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].content[]` | `union-variants-changed` | `version-bump` | -| `event:session/title-llm-request.data.messages[].source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` | -| `event:team/member.data.version` | `type-changed` | `version-bump` | -| `event:team/message/delivered.data.version` | `type-changed` | `version-bump` | -| `event:team/message/queued.data.message.content[]` | `union-variants-changed` | `version-bump` | -| `event:team/message/queued.data.message.delivery` | `property-removed` | `version-bump` | -| `event:team/message/queued.data.version` | `type-changed` | `version-bump` | -| `event:team/task.data.version` | `type-changed` | `version-bump` | -| `event:tool/code-dispatch.data.content[]` | `union-variants-changed` | `version-bump` | -| `event:tool/result.data.message.content[0].content[]` | `union-variants-changed` | `version-bump` | -| `event:user/message.data.content[]` | `union-variants-changed` | `version-bump` | -| `event:user/message.data.source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` | +| `JsonlHeaderLine.seedLength` | `property-removed` | `review-required` | +| `JsonlHeaderLine.isSeeded` | `required-property-added` | `review-required` | +| `SessionHeader.version` | `type-changed` | `review-required` | +| `event:agent/inbox/spliced.data.inserted[].content[]` | `union-variants-changed` | `review-required` | +| `event:agent/inbox/spliced.data.inserted[].source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` | +| `event:assistant/attempt` | `root-added` | `not-required` | +| `event:assistant/chunk` | `root-removed` | `review-required` | +| `event:assistant/message.data.message.content[]` | `union-variants-changed` | `review-required` | +| `event:assistant/message.data.stream` | `required-property-added` | `review-required` | +| `event:compaction/summary.data` | `union-variants-changed` | `review-required` | +| `event:session-log-deepseek/delivery-accepted.data.sessionFormatVersion` | `optional-property-added` | `not-required` | +| `event:session/end-seed.data.inherited` | `optional-property-added` | `not-required` | +| `event:session/end-seed.data` | `index-signature-changed` | `review-required` | +| `event:session/title-llm-request.data.messages[].content[]` | `union-variants-changed` | `review-required` | +| `event:session/title-llm-request.data.messages[].source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` | +| `event:team/member.data.version` | `type-changed` | `review-required` | +| `event:team/message/delivered.data.version` | `type-changed` | `review-required` | +| `event:team/message/queued.data.message.content[]` | `union-variants-changed` | `review-required` | +| `event:team/message/queued.data.message.delivery` | `property-removed` | `review-required` | +| `event:team/message/queued.data.version` | `type-changed` | `review-required` | +| `event:team/task.data.version` | `type-changed` | `review-required` | +| `event:tool/code-dispatch.data.content[]` | `union-variants-changed` | `review-required` | +| `event:tool/result.data.message.content[0].content[]` | `union-variants-changed` | `review-required` | +| `event:user/message.data.content[]` | `union-variants-changed` | `review-required` | +| `event:user/message.data.source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.i18n.yaml index 934ea2566a..f3f248ee3b 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-3-alpha-2/structural-changes: - en: be5d82540eb344db - zh: 8edd0ca270019992 + en: 4b866a071a7d653e + zh: a2ffe1a9ae361e2c /persistence-release-dsh-v0-1-3-alpha-2/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.md b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.md index d6b6a658c3..99407651a3 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.md +++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.md @@ -62,12 +62,12 @@ changes: -Detected 2 changed roots and 2 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 2 changed roots and 2 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `event:feedback/message-delete` | `root-added` | `same-version` | -| `event:feedback/message-put` | `root-added` | `same-version` | +| `event:feedback/message-delete` | `root-added` | `not-required` | +| `event:feedback/message-put` | `root-added` | `not-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.zh.md b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.zh.md index 9f6d962138..913f9cfccf 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.zh.md @@ -62,12 +62,12 @@ changes: -检测到 2 个根类型变化、2 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 2 个根类型变化、2 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `event:feedback/message-delete` | `root-added` | `same-version` | -| `event:feedback/message-put` | `root-added` | `same-version` | +| `event:feedback/message-delete` | `root-added` | `not-required` | +| `event:feedback/message-put` | `root-added` | `not-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.i18n.yaml index 5db80e034e..4896e684f0 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-5-alpha-1/structural-changes: - en: f65ce6c033426a9d - zh: 278e167343e54fef + en: b5f68991a076ac50 + zh: 4593e46ada9541e9 /persistence-release-dsh-v0-1-5-alpha-1/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.md b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.md index 3dd251e6ca..d7c9dcb531 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.md +++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.md @@ -92,35 +92,35 @@ changes: -Detected 12 changed roots and 25 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 12 changed roots and 25 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` | -| `SessionHeader.version` | `type-changed` | `version-bump` | -| `event:assistant/message.sourceEventSeqs` | `property-removed` | `version-bump` | -| `event:assistant/message.surfaceOp` | `property-made-required` | `version-bump` | -| `event:assistant/message.surfaceOp.end` | `property-removed` | `version-bump` | -| `event:assistant/message.surfaceOp.start` | `property-removed` | `version-bump` | -| `event:assistant/message.surfaceOp.endSeq` | `required-property-added` | `version-bump` | -| `event:assistant/message.surfaceOp.startSeq` | `required-property-added` | `version-bump` | -| `event:request/context.data.systemPromptUpdate` | `optional-property-added` | `same-version` | -| `event:request/header.data.header.system` | `property-removed` | `version-bump` | -| `event:system/message` | `root-added` | `version-bump` | -| `event:tool/code-dispatch` | `root-removed` | `version-bump` | -| `event:tool/code-dispatch-start` | `root-removed` | `version-bump` | -| `event:tool/ptc-dispatch` | `root-added` | `same-version` | -| `event:tool/ptc-dispatch-start` | `root-added` | `same-version` | -| `event:tool/result.surfaceOp` | `property-made-required` | `version-bump` | -| `event:tool/result.surfaceOp.end` | `property-removed` | `version-bump` | -| `event:tool/result.surfaceOp.start` | `property-removed` | `version-bump` | -| `event:tool/result.surfaceOp.endSeq` | `required-property-added` | `version-bump` | -| `event:tool/result.surfaceOp.startSeq` | `required-property-added` | `version-bump` | -| `event:user/message.surfaceOp` | `property-made-required` | `version-bump` | -| `event:user/message.surfaceOp.end` | `property-removed` | `version-bump` | -| `event:user/message.surfaceOp.start` | `property-removed` | `version-bump` | -| `event:user/message.surfaceOp.endSeq` | `required-property-added` | `version-bump` | -| `event:user/message.surfaceOp.startSeq` | `required-property-added` | `version-bump` | +| `SessionEventEnvelope` | `union-variants-changed` | `review-required` | +| `SessionHeader.version` | `type-changed` | `review-required` | +| `event:assistant/message.sourceEventSeqs` | `property-removed` | `review-required` | +| `event:assistant/message.surfaceOp` | `property-made-required` | `review-required` | +| `event:assistant/message.surfaceOp.end` | `property-removed` | `review-required` | +| `event:assistant/message.surfaceOp.start` | `property-removed` | `review-required` | +| `event:assistant/message.surfaceOp.endSeq` | `required-property-added` | `review-required` | +| `event:assistant/message.surfaceOp.startSeq` | `required-property-added` | `review-required` | +| `event:request/context.data.systemPromptUpdate` | `optional-property-added` | `not-required` | +| `event:request/header.data.header.system` | `property-removed` | `review-required` | +| `event:system/message` | `root-added` | `review-required` | +| `event:tool/code-dispatch` | `root-removed` | `review-required` | +| `event:tool/code-dispatch-start` | `root-removed` | `review-required` | +| `event:tool/ptc-dispatch` | `root-added` | `not-required` | +| `event:tool/ptc-dispatch-start` | `root-added` | `not-required` | +| `event:tool/result.surfaceOp` | `property-made-required` | `review-required` | +| `event:tool/result.surfaceOp.end` | `property-removed` | `review-required` | +| `event:tool/result.surfaceOp.start` | `property-removed` | `review-required` | +| `event:tool/result.surfaceOp.endSeq` | `required-property-added` | `review-required` | +| `event:tool/result.surfaceOp.startSeq` | `required-property-added` | `review-required` | +| `event:user/message.surfaceOp` | `property-made-required` | `review-required` | +| `event:user/message.surfaceOp.end` | `property-removed` | `review-required` | +| `event:user/message.surfaceOp.start` | `property-removed` | `review-required` | +| `event:user/message.surfaceOp.endSeq` | `required-property-added` | `review-required` | +| `event:user/message.surfaceOp.startSeq` | `required-property-added` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.zh.md b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.zh.md index fb2e241250..1e5af66f0a 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.zh.md @@ -92,35 +92,35 @@ changes: -检测到 12 个根类型变化、25 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 12 个根类型变化、25 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` | -| `SessionHeader.version` | `type-changed` | `version-bump` | -| `event:assistant/message.sourceEventSeqs` | `property-removed` | `version-bump` | -| `event:assistant/message.surfaceOp` | `property-made-required` | `version-bump` | -| `event:assistant/message.surfaceOp.end` | `property-removed` | `version-bump` | -| `event:assistant/message.surfaceOp.start` | `property-removed` | `version-bump` | -| `event:assistant/message.surfaceOp.endSeq` | `required-property-added` | `version-bump` | -| `event:assistant/message.surfaceOp.startSeq` | `required-property-added` | `version-bump` | -| `event:request/context.data.systemPromptUpdate` | `optional-property-added` | `same-version` | -| `event:request/header.data.header.system` | `property-removed` | `version-bump` | -| `event:system/message` | `root-added` | `version-bump` | -| `event:tool/code-dispatch` | `root-removed` | `version-bump` | -| `event:tool/code-dispatch-start` | `root-removed` | `version-bump` | -| `event:tool/ptc-dispatch` | `root-added` | `same-version` | -| `event:tool/ptc-dispatch-start` | `root-added` | `same-version` | -| `event:tool/result.surfaceOp` | `property-made-required` | `version-bump` | -| `event:tool/result.surfaceOp.end` | `property-removed` | `version-bump` | -| `event:tool/result.surfaceOp.start` | `property-removed` | `version-bump` | -| `event:tool/result.surfaceOp.endSeq` | `required-property-added` | `version-bump` | -| `event:tool/result.surfaceOp.startSeq` | `required-property-added` | `version-bump` | -| `event:user/message.surfaceOp` | `property-made-required` | `version-bump` | -| `event:user/message.surfaceOp.end` | `property-removed` | `version-bump` | -| `event:user/message.surfaceOp.start` | `property-removed` | `version-bump` | -| `event:user/message.surfaceOp.endSeq` | `required-property-added` | `version-bump` | -| `event:user/message.surfaceOp.startSeq` | `required-property-added` | `version-bump` | +| `SessionEventEnvelope` | `union-variants-changed` | `review-required` | +| `SessionHeader.version` | `type-changed` | `review-required` | +| `event:assistant/message.sourceEventSeqs` | `property-removed` | `review-required` | +| `event:assistant/message.surfaceOp` | `property-made-required` | `review-required` | +| `event:assistant/message.surfaceOp.end` | `property-removed` | `review-required` | +| `event:assistant/message.surfaceOp.start` | `property-removed` | `review-required` | +| `event:assistant/message.surfaceOp.endSeq` | `required-property-added` | `review-required` | +| `event:assistant/message.surfaceOp.startSeq` | `required-property-added` | `review-required` | +| `event:request/context.data.systemPromptUpdate` | `optional-property-added` | `not-required` | +| `event:request/header.data.header.system` | `property-removed` | `review-required` | +| `event:system/message` | `root-added` | `review-required` | +| `event:tool/code-dispatch` | `root-removed` | `review-required` | +| `event:tool/code-dispatch-start` | `root-removed` | `review-required` | +| `event:tool/ptc-dispatch` | `root-added` | `not-required` | +| `event:tool/ptc-dispatch-start` | `root-added` | `not-required` | +| `event:tool/result.surfaceOp` | `property-made-required` | `review-required` | +| `event:tool/result.surfaceOp.end` | `property-removed` | `review-required` | +| `event:tool/result.surfaceOp.start` | `property-removed` | `review-required` | +| `event:tool/result.surfaceOp.endSeq` | `required-property-added` | `review-required` | +| `event:tool/result.surfaceOp.startSeq` | `required-property-added` | `review-required` | +| `event:user/message.surfaceOp` | `property-made-required` | `review-required` | +| `event:user/message.surfaceOp.end` | `property-removed` | `review-required` | +| `event:user/message.surfaceOp.start` | `property-removed` | `review-required` | +| `event:user/message.surfaceOp.endSeq` | `required-property-added` | `review-required` | +| `event:user/message.surfaceOp.startSeq` | `required-property-added` | `review-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.i18n.yaml index 6aceb0a20f..03e9c9e64d 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.i18n.yaml +++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.i18n.yaml @@ -21,8 +21,8 @@ en: af9cea43334ee692 zh: c6c8bd0d6a17718a /persistence-release-dsh-v0-1-5-alpha-2/structural-changes: - en: a142ebb2293bce41 - zh: 488e9e2321625d51 + en: 5a12854a5c81d51e + zh: dc924c0138b2e57a /persistence-release-dsh-v0-1-5-alpha-2/verification: en: 8ccca317964523b1 zh: 2c8b3e70a3320778 diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.md b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.md index 76353e3ab3..b6db727160 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.md +++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.md @@ -68,15 +68,15 @@ changes: -Detected 4 changed roots and 5 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. +Detected 4 changed roots and 5 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility. -| Path | Change | Current minimum | +| Path | Change | Review requirement | |---|---|---| -| `event:deliverables/presented` | `root-added` | `same-version` | -| `event:feedback/message-put.data.item.category` | `optional-property-added` | `same-version` | -| `event:feedback/record.data.text` | `property-made-optional` | `same-version` | -| `event:feedback/record.data.category` | `optional-property-added` | `same-version` | -| `event:subagent/catalog` | `root-added` | `same-version` | +| `event:deliverables/presented` | `root-added` | `not-required` | +| `event:feedback/message-put.data.item.category` | `optional-property-added` | `not-required` | +| `event:feedback/record.data.text` | `property-made-optional` | `not-required` | +| `event:feedback/record.data.category` | `optional-property-added` | `not-required` | +| `event:subagent/catalog` | `root-added` | `not-required` | diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.zh.md b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.zh.md index 676a09c1e6..37e01ce888 100644 --- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.zh.md +++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.zh.md @@ -68,15 +68,15 @@ changes: -检测到 4 个根类型变化、5 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 +检测到 4 个根类型变化、5 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。 -| 路径 | 变化 | 当前最低要求 | +| 路径 | 变化 | 审查要求 | |---|---|---| -| `event:deliverables/presented` | `root-added` | `same-version` | -| `event:feedback/message-put.data.item.category` | `optional-property-added` | `same-version` | -| `event:feedback/record.data.text` | `property-made-optional` | `same-version` | -| `event:feedback/record.data.category` | `optional-property-added` | `same-version` | -| `event:subagent/catalog` | `root-added` | `same-version` | +| `event:deliverables/presented` | `root-added` | `not-required` | +| `event:feedback/message-put.data.item.category` | `optional-property-added` | `not-required` | +| `event:feedback/record.data.text` | `property-made-optional` | `not-required` | +| `event:feedback/record.data.category` | `optional-property-added` | `not-required` | +| `event:subagent/catalog` | `root-added` | `not-required` | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 4cf676dbc6..23d8e571e4 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22052,7 +22052,7 @@ "packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData" ], "sources": [ - "packages/session/session-title-llm/src/index.ts:34" + "packages/session/session-title-llm/src/index.ts:37" ] }, { @@ -23137,7 +23137,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:398", + "packages/api/session-controller/src/types.ts:408", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -23582,7 +23582,7 @@ "digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7", "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:403" + "packages/api/session-controller/src/types.ts:413" ] }, { @@ -25173,7 +25173,7 @@ "digest": "dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd", "names": [], "sources": [ - "packages/core/tools/src/ptc.ts:14" + "packages/core/tools/src/ptc.ts:15" ] }, { diff --git a/docs/session-format-status.i18n.yaml b/docs/session-format-status.i18n.yaml index ef3b64d39c..0f82e86719 100644 --- a/docs/session-format-status.i18n.yaml +++ b/docs/session-format-status.i18n.yaml @@ -15,14 +15,14 @@ en: 02d18ba8a66cfb10 zh: 54b753bbcb8f8a9e /session-format-version-and-release-status/finalization-record: - en: 15b6134dcb3a09d5 - zh: 4260c82e79baa021 + en: c84a45a0489804c9 + zh: 4f4474a678c90ffe /session-format-version-and-release-status/release-record: en: 83ce79a241b630d7 zh: 53f3c461ef731f67 /session-format-version-and-release-status/updating-the-record: - en: 7eaa730a2d163bd6 - zh: 53ed7618537db2f5 + en: 0d6e8f65604b1394 + zh: 0c8dd27ad4d37649 /session-format-version-and-release-status/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/docs/session-format-status.md b/docs/session-format-status.md index 78b6701095..9cfc59b643 100644 --- a/docs/session-format-status.md +++ b/docs/session-format-status.md @@ -32,7 +32,7 @@ The [format references](persistence-changes/historical-formats/README.md) docume latestFinalizedVersion: 4 ``` -V4 has an accepted compatibility baseline in the [checkpoint](persistence-changes/finalized/v4.json). Backward-compatible schema changes may remain V4 through new acknowledgement records. Breaking changes require a higher writer version and their own header transition; they cannot reuse the accepted 3→4 transition. Accepted machine records and after schemas remain immutable. [Checkpoint rules](persistence-changes/README.md#compatibility-rules) define the comparison. +V4 has an accepted compatibility baseline in the [checkpoint](persistence-changes/finalized/v4.json). Schema changes may remain V4 when the [reader-compatibility rules](persistence-changes/README.md#compatibility-rules) are satisfied and the required review is recorded. A version-bump decision requires a higher writer version and its own header transition; it cannot reuse the accepted 3→4 transition. Accepted machine records and after schemas remain immutable. Finalization does not freeze every future V4 addition and does not assert publication. The release record below retains the independently verified published version. Ordinary comments, aliases, source locations, and implementation fixes preserving the accepted meaning do not change this baseline. @@ -51,7 +51,7 @@ Evidence: published product tag `dsh-v0.2.0-rc.2`; tagged writer: `packages/core ## Updating the record -When a structural writer change is implemented, update the code constant and adjacent catalog together; do not advance this release record before publication. When a product release first publishes a higher Session format, confirm publication and its tagged writer, then advance this record and the evidence tag and tagged writer path in the same bilingual update. Later product releases carrying the same format do not require changing the record. Never lower it on the development trunk. +When a Session format bump is implemented, update the code constant and adjacent catalog together; do not advance this release record before publication. When a product release first publishes a higher Session format, confirm publication and its tagged writer, then advance this record and the evidence tag and tagged writer path in the same bilingual update. Later product releases carrying the same format do not require changing the record. Never lower it on the development trunk. The [documentation-standard test](../scripts/doc-standard.spec.ts) checks record structure, bilingual equality, evidence-tag and writer-path consistency, and that the documented release does not exceed the checkout writer. This keyless check does not query GitHub or prove that the record is up to date; publication verification remains part of the release update. diff --git a/docs/session-format-status.zh.md b/docs/session-format-status.zh.md index 141917a63c..0317995c67 100644 --- a/docs/session-format-status.zh.md +++ b/docs/session-format-status.zh.md @@ -32,7 +32,7 @@ latestFinalizedVersion: 4 ``` -V4 的已接受兼容性基线保存在[检查点](persistence-changes/finalized/v4.json)中。向后兼容的 schema 变更可以通过新的确认记录保留 V4。破坏性变更要求更高的写入器版本及自身的头版本转换,不能复用已接受的 3→4 转换。已接受的机器记录与变更后 schema 保持不可变。[检查点规则](persistence-changes/README.zh.md#compatibility-rules)规定比较方法。 +V4 的已接受兼容性基线保存在[检查点](persistence-changes/finalized/v4.json)中。满足[读取器兼容性规则](persistence-changes/README.zh.md#compatibility-rules)并记录所需评审时,schema 变更可以保留 V4。升版本决策要求更高的写入器版本及自身的头版本转换,不能复用已接受的 3→4 转换。已接受的机器记录与变更后 schema 保持不可变。 定稿不冻结 V4 之后的每项新增,也不表示已发布。下方发布记录保留独立验证的已发布版本。普通注释、别名、源码位置,以及保留已接受含义的实现修复,不改变该基线。 @@ -51,7 +51,7 @@ evidenceTag: dsh-v0.2.0-rc.2 ## 更新记录 -实现结构性写入器变更时,一起更新代码常量与相邻迁移目录;不要在产品发布前推进此发布记录。当产品首次发布更高的 Session 格式时,确认发布事实及对应标签的写入器,然后在同一次双语更新中推进本记录与证据标签及该标签的写入器路径。后续携带相同格式的产品发布无需改变此记录。开发主干上的记录绝不降低。 +实现 Session 格式升版本时,一起更新代码常量与相邻迁移目录;不要在产品发布前推进此发布记录。当产品首次发布更高的 Session 格式时,确认发布事实及对应标签的写入器,然后在同一次双语更新中推进本记录与证据标签及该标签的写入器路径。后续携带相同格式的产品发布无需改变此记录。开发主干上的记录绝不降低。 [文档标准测试](../scripts/doc-standard.spec.ts)检查记录结构、双语一致性、证据标签及写入器路径一致性,以及文档中的已发布版本不高于工作区写入器。这个无密钥检查不会查询 GitHub,也不能证明记录是最新的;核实发布事实仍属于发布更新的一部分。 diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index b8f7380bbf..d53eb92d3b 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -16,7 +16,7 @@ zh: 787ad4d04b79adaf /conversation-assembly/replayable-event-families: en: c216e2df128b8cb9 - zh: e61312f1f280332d + zh: c039cf84b0d86ad8 /conversation-assembly/definition-and-typed-chat-payload: en: 5f24557a050d1df0 zh: 47735627f802b6fa diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 7115bb90b8..7678e2b78c 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -71,6 +71,8 @@ Group 存储在安装前校验完整提交结果:根 Group 引用与记录一 实时 Assistant delta 作为 Client-only `assistant/live-chunk` event 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 可以初始化 Context。每条工具 delta 和后续 `tool/call` 都可以按同一个 callId 匹配为 start,即使名称尚未到达;只有最早的 Match 调用 `start()`,后续 Match 调用 `update()`。准备态 Tool 节点只有在名称可用后才显示。历史分页不把已结束消息展开为实时 delta。消费 Assistant 输出的 Definition 通过同一组匹配和更新接口处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。 + + ## Definition 与类型化 Chat payload 为了完整展示关联关系,下面把生产方声明和 Client 贡献写在同一个代码块里。实际的包族中,branded id 与 `SessionEventMap` 声明留在事件生产方,Definition、Chat data 合并与 renderer 留在 Client 插件。 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index c4c106c066..94bed76054 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -25,7 +25,7 @@ zh: d0f66664ac85ae64 /core/tooldefinition: en: d926a9b80aada2e5 - zh: e34e6f43bdfd8920 + zh: 7e191ba5dab904df /core/repo-wide-type-patterns: en: 057db6d8bf356c0f zh: 1441b39e5142b0bf diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index c6c02c6c42..42f6d2e24a 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -557,7 +557,9 @@ serviceFor(agent: { ctx: Context }, name: K): */ async recompose(ctx: Context, id: string): Promise -/** Select a preset before a session starts its first turn. +/** Select a preset before a session starts its first turn. A Session whose + * log records the preset and whose Agent runs its current revision keeps its + * composition; nothing is logged or announced. * @param agent Target Agent. * @param agentPreset Requested identity. * @returns Committed preset identity. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 582a817f1f..9714c7c62f 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -363,6 +363,8 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' 其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 类型,以及工具展示 UI 类型都在 **[tools.md](tools.zh.md)** 中。 + + ## 全仓通用类型模式 两个模式在每个子系统中反复出现,只在此处记录一次。 @@ -567,7 +569,9 @@ serviceFor(agent: { ctx: Context }, name: K): */ async recompose(ctx: Context, id: string): Promise -/** Select a preset before a session starts its first turn. +/** Select a preset before a session starts its first turn. A Session whose + * log records the preset and whose Agent runs its current revision keeps its + * composition; nothing is logged or announced. * @param agent Target Agent. * @param agentPreset Requested identity. * @returns Committed preset identity. diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 8491f61b6d..c5426ddc70 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -42,8 +42,8 @@ en: f2e6932e211f179b zh: 75112586a6f3f4aa /llm-streaming/official-deepseek-request-extensions: - en: b19f291f094157ed - zh: 682f13a833e8bb52 + en: 42bc50394b070e96 + zh: 007caa743ec3c790 /llm-streaming/service-and-provider-contracts: en: 8dc79d4cc82824c2 zh: 3ebabd3a117ad717 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index f87c1e9d45..4936ba4ae8 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -796,7 +796,7 @@ interface LlmCallConfigAdapterDefaults { ## Official DeepSeek request extensions -`ctx.deepseekLlmApiExtensions` is the provider-specific registry for additive top-level fields on `deepseek-official` requests. Contributor plugins use `register(field, provider)` to claim one field; the adapter calls `prepare(request)` after serializing its base body and merges the returned fields before HTTP. The prepared `accept()` transaction runs after 2xx, so a contributor can commit delivery state without treating a transport or provider rejection as acceptance. Preparation, collision, and acceptance failures use `REQUEST_EXTENSION` and fail the model request. A merged body that fails to serialize is sent without extension fields; acceptance is skipped and the provider plugin logs the omitted field names. +`ctx.deepseekLlmApiExtensions` is the provider-specific registry for additive top-level fields on `deepseek-official` requests. Contributor plugins use `register(field, provider)` to claim one field; the adapter calls `prepare(request)` after serializing its base body and merges the returned fields before HTTP. The prepared `accept()` transaction runs after 2xx, so a contributor can commit delivery state without treating a transport or provider rejection as acceptance. A contributor whose preparation throws is logged and omitted from that request, and an acceptance failure is logged without failing the request; a base-field collision uses `REQUEST_EXTENSION` and fails the model request. A merged body that fails to serialize is sent without extension fields; acceptance is skipped and the provider plugin logs the omitted field names. The [wire reference](../deepseek-llm-api-wire-extensions.md) defines the exact request headers, extension transaction, field versions, and receiver obligations. The shipped composition registers [`dsh_session_log`](../../packages/session/session-log-deepseek/README.md) as a lossless incremental canonical-log suffix and [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.md) as the complete active Loader-backed package set. These fields remain outside model messages and are absent from the pi-ai adapter path. @@ -931,8 +931,9 @@ register( field: K, provider: DeepSe /** * Prepare every currently registered field from one immutable base request. - * Preparation failures reject before HTTP dispatch. Field values are cloned and frozen; - * providers retain no mutable alias to the outgoing request. + * A provider whose preparation throws, or whose value cannot be cloned, is omitted + * from this request; the first such failure per field is logged. Only cancellation rejects. Field values are cloned + * and frozen; providers retain no mutable alias to the outgoing request. * @param request - exact serialized request facts before extension fields. * @returns detached fields and their idempotent joint acceptance transaction. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 2f3c90e096..47d7c0df31 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -802,7 +802,7 @@ interface LlmCallConfigAdapterDefaults { ## DeepSeek 官方请求扩展 -`ctx.deepseekLlmApiExtensions` 是用于向 `deepseek-official` 请求添加顶层字段的提供方特定注册表。贡献插件通过 `register(field, provider)` 认领一个字段;适配器在序列化基础正文后调用 `prepare(request)`,并在 HTTP 前合并返回字段。已准备的 `accept()` 事务会在 2xx 后运行,因此贡献方可以提交交付状态,而不会把传输失败或提供方拒绝当作接受。准备、冲突与接受失败会使用 `REQUEST_EXTENSION`,并使模型请求失败。合并后的正文无法序列化时,请求不带扩展字段发出,跳过接受,并由提供方插件记录被省略的字段名。 +`ctx.deepseekLlmApiExtensions` 是用于向 `deepseek-official` 请求添加顶层字段的提供方特定注册表。贡献插件通过 `register(field, provider)` 认领一个字段;适配器在序列化基础正文后调用 `prepare(request)`,并在 HTTP 前合并返回字段。已准备的 `accept()` 事务会在 2xx 后运行,因此贡献方可以提交交付状态,而不会把传输失败或提供方拒绝当作接受。贡献方准备时抛错会被记录并在该次请求中省略,接受失败只记录告警而不使请求失败;与基础字段冲突时使用 `REQUEST_EXTENSION` 并使模型请求失败。合并后的正文无法序列化时,请求不带扩展字段发出,跳过接受,并由提供方插件记录被省略的字段名。 [协议参考](../deepseek-llm-api-wire-extensions.zh.md)定义确切的请求标头、扩展事务、字段版本和接收方义务。随附组合会将 [`dsh_session_log`](../../packages/session/session-log-deepseek/README.zh.md) 注册为无损增量权威日志后缀,并将 [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.zh.md) 注册为完整存活 Loader 包集合。这些字段仍位于模型消息之外,也不会进入 pi-ai 适配器路径。 @@ -937,8 +937,9 @@ register( field: K, provider: DeepSe /** * Prepare every currently registered field from one immutable base request. - * Preparation failures reject before HTTP dispatch. Field values are cloned and frozen; - * providers retain no mutable alias to the outgoing request. + * A provider whose preparation throws, or whose value cannot be cloned, is omitted + * from this request; the first such failure per field is logged. Only cancellation rejects. Field values are cloned + * and frozen; providers retain no mutable alias to the outgoing request. * @param request - exact serialized request facts before extension fields. * @returns detached fields and their idempotent joint acceptance transaction. */ diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 7c6b47deb1..3c0c2586a7 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -311,6 +311,8 @@ type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> interface SessionPersistenceSnapshot { /** Detached metadata for one stored session. */ readonly header: SessionHeader + /** Header-only format classification; absent without versioned artifact metadata. */ + readonly formatStatus?: 'current' | 'migration-required' /** Opaque change token; see {@link SessionPersistence.stat}. */ readonly revision: SessionPersistenceRevision /** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */ diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 2883db7e22..dba0685409 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -311,6 +311,8 @@ type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> interface SessionPersistenceSnapshot { /** Detached metadata for one stored session. */ readonly header: SessionHeader + /** Header-only format classification; absent without versioned artifact metadata. */ + readonly formatStatus?: 'current' | 'migration-required' /** Opaque change token; see {@link SessionPersistence.stat}. */ readonly revision: SessionPersistenceRevision /** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */ diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index 6ec7b89a19..e5ce0f0902 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.i18n.yaml @@ -15,8 +15,8 @@ en: ee04468005fae8e1 zh: 1347d554102951f2 /plan-mode/the-exit-tool-and-the-plan-command: - en: ecf7fe520658b10f - zh: 9d6d2c4729e9a47e + en: b5bbc440552d7ade + zh: 17fb1cfed1bc48ef /plan-mode/the-service: en: 07c3eec12e0dc75c zh: 183d73f6672e2c5a diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 1c29a87851..beba872f61 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -30,7 +30,7 @@ A missing, blank, or non-string `section` and any unknown key fail at plugin loa ## The exit tool and the `/plan` command -[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so entering or leaving plan mode changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-questions seam](user-questions.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again; a missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode. +[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so entering or leaving plan mode changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-questions seam](user-questions.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again. A dismissed review returns `{ approved: false }` and concludes the turn, so plan mode stays active until the user's next message. A missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode. When [`ctx.commands`](commands.md) is composed, the plugin registers `/plan [off|message]`: bare `/plan` selects plan mode, any other non-empty message selects it and then submits the text through `agent.steer()` so it becomes the next step's ordinary logged user message under plan guidance, and the exact argument `off` selects inactive, which also cancels a pending entry before it is appended and becomes visible to a request. diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index db2a7e1c2b..63463f0105 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -30,7 +30,7 @@ interface PlanModeConfig { ## 退出工具与 `/plan` 命令 -[`exit_plan_mode`](../tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-questions.zh.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待生效退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交;评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。 +[`exit_plan_mode`](../tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-questions.zh.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待生效退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交。放弃评审返回 `{ approved: false }` 并结束当前轮次,计划模式保持激活,直到用户发出下一条消息。评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。 当 [`ctx.commands`](commands.zh.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在待生效条目被追加并对请求可见之前将其取消。 diff --git a/docs/subsystems/ptc-runtime.i18n.yaml b/docs/subsystems/ptc-runtime.i18n.yaml index 4ad1978544..7c6f634157 100644 --- a/docs/subsystems/ptc-runtime.i18n.yaml +++ b/docs/subsystems/ptc-runtime.i18n.yaml @@ -6,14 +6,14 @@ en: 337a6580d7571274 zh: f4ae98899120d122 /ptc-runtime/the-run-request-in-result-out: - en: b11e176bee0ca6bd - zh: 843d5d70c58dec42 + en: 0305ed1d4f8bc618 + zh: fcfeb156574b5779 /ptc-runtime/bindings-host-functions-as-program-globals: en: 182147ddf388c600 zh: c0a7d77602c78bcd /ptc-runtime/captured-output-and-the-failure-taxonomy: - en: e5431173436c00c2 - zh: 4ba58e259bbadbc8 + en: 8eb1607888ab6efc + zh: 580c852d17b9413f /ptc-runtime/the-service: en: a82f828fa006c7e9 zh: 2279d36867335b57 diff --git a/docs/subsystems/ptc-runtime.md b/docs/subsystems/ptc-runtime.md index efab5bcefa..d9ad8138a0 100644 --- a/docs/subsystems/ptc-runtime.md +++ b/docs/subsystems/ptc-runtime.md @@ -18,11 +18,18 @@ Source: [`packages/ptc-runtime/ptc-runtime/src/types.ts`](../../packages/ptc-run interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It - * runs as the body of an async function: top-level `await` and `return` - * are available, and the completion value becomes + * follows the provider's programFormat: an async function body supports + * top-level `await` and `return`; a REPL supports top-level declarations, + * `await`, and a final expression. The completion value becomes * {@link PtcRunResult.value}. */ program: string + /** + * Object-identity owner for optional in-memory state. Omission requests a fresh + * environment. Consumers must await runtime.release(owner) when the owner ends. + * Stateless providers ignore this field; retained state is never durable. + */ + stateOwner?: object /** Host functions exposed to the program, one global object per namespace. */ bindings: PtcBindingNamespace[] /** Working directory in the mounted filesystem and subprocess execution world. */ @@ -77,7 +84,7 @@ interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (its top-level `return`), when it ran to + * The program's completion value (a return or final REPL expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. @@ -94,6 +101,8 @@ interface PtcRunResult { } ``` +`programFormat` distinguishes async function bodies from REPL statements. `stateOwner` is an in-process object identity for optional retained memory; distinct objects remain isolated. Consumers await `release(owner)` when that owner ends; cleanup failures reject that call. Omitted owners are one-shot, and stateless providers ignore ownership. The [persistent Node provider](../../packages/ptc-runtime/ptc-runtime-node/README.md#persistent-repl) defines reset and recovery behavior; no retained object enters the durable Session format. + ## Bindings: host functions as program globals Each `PtcBindingNamespace` becomes a global object of async callables; PTC passes `tools`. Arguments and resolutions must be lossless JSON. Providers enforce their own transport caps; the seam sets no uniform binding-byte limit. An optional error-class descriptor creates program-visible typed rejections without naming a consumer inside the runtime. Binding names are own properties, so `__proto__` cannot traverse a prototype: @@ -168,12 +177,12 @@ type PtcBindingFunction = (args: unknown) => Promise Logs are plain strings. Each source channel preserves emission order, while interleaving across independent channels is backend-dependent because channel metadata is not part of the seam. The runtime captures the program's console and stream output, and consumers render only the text. Implementations cap the serialized outer log-array plus completion-value or failure-message payload; fixed result-envelope syntax and consumer presentation whitespace are not part of that variable-payload ledger. Overflow is an explicit failure rather than in-band value substitution. -Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: +Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns/general.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: ```ts type-equiv /** * Why a run failed. The kinds are orthogonal outcomes reported independently - * (per docs/defensive-patterns.md): a budget expiry is not an exception, an + * (per docs/defensive-patterns/general.md): a budget expiry is not an exception, an * abort is not a timeout, and a substrate death is neither. * * - `'exception'` — the program threw or failed to parse/transform. @@ -209,9 +218,18 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.ptcRuntime` — `PtcRuntime` (abstract seam) -Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. +Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate distinct state owners, and terminate and await in-flight runs during disposal. ```ts cordis-catalog +/** + * Release an owner's retained environment and await its execution resources. + * Stateless providers have no retained resources to release. + * @param owner - The exact object supplied as the request's state owner. + * @returns Completion after all owned execution resources have been released. + * @throws When managed resource cleanup fails. + */ +release(owner: object): Promise + /** * Resolve supported options and provider defaults before execution. * @param request - Program, bindings, cancellation and optional execution choices. diff --git a/docs/subsystems/ptc-runtime.zh.md b/docs/subsystems/ptc-runtime.zh.md index f35c5bd84d..ba747b5369 100644 --- a/docs/subsystems/ptc-runtime.zh.md +++ b/docs/subsystems/ptc-runtime.zh.md @@ -18,11 +18,18 @@ PTC 执行[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13- interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It - * runs as the body of an async function: top-level `await` and `return` - * are available, and the completion value becomes + * follows the provider's programFormat: an async function body supports + * top-level `await` and `return`; a REPL supports top-level declarations, + * `await`, and a final expression. The completion value becomes * {@link PtcRunResult.value}. */ program: string + /** + * Object-identity owner for optional in-memory state. Omission requests a fresh + * environment. Consumers must await runtime.release(owner) when the owner ends. + * Stateless providers ignore this field; retained state is never durable. + */ + stateOwner?: object /** Host functions exposed to the program, one global object per namespace. */ bindings: PtcBindingNamespace[] /** Working directory in the mounted filesystem and subprocess execution world. */ @@ -77,7 +84,7 @@ interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (its top-level `return`), when it ran to + * The program's completion value (a return or final REPL expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. @@ -94,6 +101,8 @@ interface PtcRunResult { } ``` +`programFormat` 区分异步函数体与 REPL 语句。`stateOwner` 是标识可选保留内存的进程内对象身份;不同对象相互隔离。消费者在所有者结束时等待 `release(owner)`;清理失败时该调用拒绝。省略所有者时仅执行一次,无状态提供方忽略所有权。[持久 Node 提供方](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md#persistent-repl)定义重置和恢复行为;保留对象不会进入持久 Session 格式。 + ## 绑定:宿主函数作为程序全局变量 每个 `PtcBindingNamespace` 成为一个异步可调用函数的全局对象;PTC 传入 `tools`。参数与返回值必须是无损 JSON。提供方强制各自的传输上限;seam 不设统一的绑定字节上限。可选错误类描述符创建程序可见的类型化拒绝,无需在运行时内点名消费方。绑定名是自有属性,因此 `__proto__` 不能遍历原型: @@ -168,12 +177,12 @@ type PtcBindingFunction = (args: unknown) => Promise 日志是纯字符串。每个来源通道保留自身的发出顺序;由于通道元数据不属于 seam,相互独立的通道如何交错由后端决定。运行时捕获程序的 console 与流输出,Consumer 只渲染文本。实现会对序列化后的外层日志数组,以及完成值或失败消息的组合载荷设置上限;固定的结果封装语法与 Consumer 展示空白不计入这份可变载荷计量。超限会显式失败,而不会在值中插入替代内容。 -失败类型是**正交的结果,独立报告**(见 [defensive-patterns](../defensive-patterns.zh.md)):预算耗尽不是异常,中止不是超时,基底崩溃(如 OOM)也不是二者中的任何一个: +失败类型是**正交的结果,独立报告**(见 [defensive-patterns](../defensive-patterns/general.zh.md)):预算耗尽不是异常,中止不是超时,基底崩溃(如 OOM)也不是二者中的任何一个: ```ts type-equiv /** * Why a run failed. The kinds are orthogonal outcomes reported independently - * (per docs/defensive-patterns.md): a budget expiry is not an exception, an + * (per docs/defensive-patterns/general.md): a budget expiry is not an exception, an * abort is not a timeout, and a substrate death is neither. * * - `'exception'` — the program threw or failed to parse/transform. @@ -209,9 +218,18 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.ptcRuntime` — `PtcRuntime` (abstract seam) -Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. +Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate distinct state owners, and terminate and await in-flight runs during disposal. ```ts cordis-catalog +/** + * Release an owner's retained environment and await its execution resources. + * Stateless providers have no retained resources to release. + * @param owner - The exact object supplied as the request's state owner. + * @returns Completion after all owned execution resources have been released. + * @throws When managed resource cleanup fails. + */ +release(owner: object): Promise + /** * Resolve supported options and provider defaults before execution. * @param request - Program, bindings, cancellation and optional execution choices. diff --git a/docs/subsystems/scope.i18n.yaml b/docs/subsystems/scope.i18n.yaml index bce762d85d..f5dd6f8a73 100644 --- a/docs/subsystems/scope.i18n.yaml +++ b/docs/subsystems/scope.i18n.yaml @@ -4,7 +4,7 @@ # pnpm run verify-translation-pairing --write docs/subsystems/scope.md /scoped-registration: en: b786b68190a93d3f - zh: 8ea0fb2fac9a1529 + zh: d487dc4263d61a53 /scoped-registration/identity-and-dispatch-carrier: en: f4e1bdd96c43a845 zh: c74409f7f81bd0e0 diff --git a/docs/subsystems/scope.zh.md b/docs/subsystems/scope.zh.md index db791a407f..c48858d6ba 100644 --- a/docs/subsystems/scope.zh.md +++ b/docs/subsystems/scope.zh.md @@ -6,6 +6,8 @@ 源码:[`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts) 与 [`packages/core/scope/src/store.ts`](../../packages/core/scope/src/store.ts)。 + + ## 身份标识与分发载体 `ScopeKey` 是一个不透明的对象身份标识。已交付的 agent loop(智能体循环)使用活跃的 `Agent` 对象作为自身的 key,但该原语从不检视该对象。 diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md index 1cf3dff325..1b07f1c698 100644 --- a/docs/subsystems/session-query.md +++ b/docs/subsystems/session-query.md @@ -24,6 +24,8 @@ interface SessionRecord { live: boolean /** Whether the active persistence backend currently lists the id, including a created-but-unmaterialized session it already observes. */ persisted: boolean + /** Current for live Sessions; otherwise the persistence format status when reported. */ + formatStatus?: 'current' | 'migration-required' } ``` diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md index 2b8504dfb9..95b9b570af 100644 --- a/docs/subsystems/session-query.zh.md +++ b/docs/subsystems/session-query.zh.md @@ -24,6 +24,8 @@ interface SessionRecord { live: boolean /** Whether the active persistence backend currently lists the id, including a created-but-unmaterialized session it already observes. */ persisted: boolean + /** Current for live Sessions; otherwise the persistence format status when reported. */ + formatStatus?: 'current' | 'migration-required' } ``` diff --git a/docs/subsystems/session-title.i18n.yaml b/docs/subsystems/session-title.i18n.yaml index a701f0ad9d..72d5fb61aa 100644 --- a/docs/subsystems/session-title.i18n.yaml +++ b/docs/subsystems/session-title.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-title.md /session-titles: - en: a0ad8d5f000e5258 - zh: 9527bc25711342de + en: 40ede027d4f62b35 + zh: 0efd7cb0710a0ed9 /session-titles/durable-title-state: en: c6be2d86647daa6f zh: fb259d9fb4738f39 /session-titles/auxiliary-request-record: - en: c36916905dad76f9 - zh: c82ff6c3d62e53e5 + en: e5d5045ddfa7e771 + zh: 8c989ef59853cce4 /session-titles/provider-input-and-output: - en: 886e036e442ae06a - zh: 49f95782b8aa9bf3 + en: 28ee84e62db773f0 + zh: 93beca6fbcd02c4e diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md index 207af94795..f953d3fcf2 100644 --- a/docs/subsystems/session-title.md +++ b/docs/subsystems/session-title.md @@ -2,7 +2,7 @@ English | [中文](session-title.zh.md) -Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session/session-title). The shared LLM helper owns the exact auxiliary request record. Package READMEs own timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete event declarations. +Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session/session-title). The shared execution module owns the exact auxiliary request record. Package READMEs own timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete event declarations. Sources: [`packages/session/session-title/src/index.ts`](../../packages/session/session-title/src/index.ts), [`packages/session/session-title-llm/src/index.ts`](../../packages/session/session-title-llm/src/index.ts) @@ -64,7 +64,7 @@ interface SessionTitleSnapshot extends SessionTitleEventData { ## Auxiliary request record -The shared LLM helper records each validated, dispatchable title request before calling the model. The payload reproduces the model-visible system and message input, routing, output limit, resolved reasoning effort, provider ownership, and source-message attribution even when generation later fails. +The shared execution module records each validated, dispatchable title request before calling the model. The payload reproduces the model-visible system and message input, routing, output limit, resolved reasoning effort, provider ownership, and source-message attribution even when generation later fails. ```ts type-equiv /** Exact model-visible request recorded before one auxiliary title dispatch. */ @@ -88,7 +88,7 @@ interface SessionTitleLlmRequestEventData { ## Provider input and output -The service snapshots eligible messages through one revision. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind. +The service snapshots eligible messages through one revision and, immediately before each provider call, the latest accepted title including the fallback it just ensured. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind. ```ts type-equiv /** One eligible human text message exposed to title providers. */ @@ -114,6 +114,12 @@ interface SessionTitleProviderRequest { readonly messages: readonly SessionTitleUserMessage[] /** Exact current logged main-request route, when one has been recorded. */ readonly route?: SessionTitleModelIdentity + /** + * Latest accepted title captured at invocation, including an accepted + * fallback whose event may follow the last eligible message. Absent before + * any title is accepted. The provider decides whether and how to use it. + */ + readonly currentTitle?: SessionTitleSnapshot /** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */ readonly signal: AbortSignal } @@ -140,7 +146,7 @@ interface SessionTitleProvider { readonly automatic: SessionTitleAutomaticMode /** * Produce one title revision. - * @param request - message snapshot, current route, session, and cancellation. + * @param request - message snapshot, current title, current route, session, and cancellation. * @returns proposed title plus exact input seqs and the optional provider/model route used to generate it. */ generate(request: SessionTitleProviderRequest): Promise diff --git a/docs/subsystems/session-title.zh.md b/docs/subsystems/session-title.zh.md index 013f8dd861..9b8b0b81f1 100644 --- a/docs/subsystems/session-title.zh.md +++ b/docs/subsystems/session-title.zh.md @@ -2,7 +2,7 @@ [English](session-title.md) | 中文 -[`@deepseek-ai/dsh-session-title`](../../packages/session/session-title) 所拥有的持久、后写覆盖的标题状态与可选异步提供方词汇。共享 LLM(大语言模型)辅助组件负责精确的辅助请求记录。各包 README 负责时序、回退、失败与 fork 行为;生成的[持久化日志事件目录](../persistence-catalog.zh.md)负责完整的事件声明。 +[`@deepseek-ai/dsh-session-title`](../../packages/session/session-title) 所拥有的持久、后写覆盖的标题状态与可选异步提供方词汇。共享执行模块负责精确的辅助请求记录。各包 README 负责时序、回退、失败与 fork 行为;生成的[持久化日志事件目录](../persistence-catalog.zh.md)负责完整的事件声明。 源码:[`packages/session/session-title/src/index.ts`](../../packages/session/session-title/src/index.ts)、[`packages/session/session-title-llm/src/index.ts`](../../packages/session/session-title-llm/src/index.ts) @@ -64,7 +64,7 @@ interface SessionTitleSnapshot extends SessionTitleEventData { ## 辅助请求记录 -共享 LLM 辅助组件会在调用模型前,记录每一项已经过验证且可分发的标题请求。即使后续生成失败,载荷仍会复现模型可见的系统输入与消息输入、路由、输出上限、解析后的推理强度、提供方归属和源消息归因。 +共享执行模块会在调用模型前,记录每一项已经过验证且可分发的标题请求。即使后续生成失败,载荷仍会复现模型可见的系统输入与消息输入、路由、输出上限、解析后的推理强度、提供方归属和源消息归因。 ```ts type-equiv /** Exact model-visible request recorded before one auxiliary title dispatch. */ @@ -88,7 +88,7 @@ interface SessionTitleLlmRequestEventData { ## 提供方输入与输出 -服务会对截至某一修订的合格消息创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限,并追加标题及其来源消息 seq 和来源类型。 +服务会对截至某一修订的合格消息、以及在每次提供方调用前刚刚确保的回退和最新已接纳标题创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限,并追加标题及其来源消息 seq 和来源类型。 ```ts type-equiv /** One eligible human text message exposed to title providers. */ @@ -114,6 +114,12 @@ interface SessionTitleProviderRequest { readonly messages: readonly SessionTitleUserMessage[] /** Exact current logged main-request route, when one has been recorded. */ readonly route?: SessionTitleModelIdentity + /** + * Latest accepted title captured at invocation, including an accepted + * fallback whose event may follow the last eligible message. Absent before + * any title is accepted. The provider decides whether and how to use it. + */ + readonly currentTitle?: SessionTitleSnapshot /** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */ readonly signal: AbortSignal } @@ -140,7 +146,7 @@ interface SessionTitleProvider { readonly automatic: SessionTitleAutomaticMode /** * Produce one title revision. - * @param request - message snapshot, current route, session, and cancellation. + * @param request - message snapshot, current title, current route, session, and cancellation. * @returns proposed title plus exact input seqs and the optional provider/model route used to generate it. */ generate(request: SessionTitleProviderRequest): Promise diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 1dd08630bb..bb40e7f93f 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -856,9 +856,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise @@ -922,10 +922,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * Fork one cold-readable exact event prefix into a new Session. An omitted * boundary selects the latest completed-turn prefix; an open cut receives * synthetic fork closers. - * @param request - source Session and optional exact inclusive event boundary. + * @param request - source Session, optional exact inclusive event boundary, and migration preflight choice. * @returns the new Session identity. */ -@Remote('fork') fork(request: SessionForkRequest): Promise +@Remote('fork') async fork(request: SessionForkRequest): Promise /** * Admit one prompt after explicitly resuming its Session. @@ -974,10 +974,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** - * Read all registered projections without activating an Agent. + * Read exact projections, returning cached hints only when migration is required. * @param request - Session whose current values are required. - * @param signal - cancellation for the Session observation. - * @returns complete baseline, or null when the Session does not exist. + * @param signal - cancellation for the Session read. + * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ @Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 8085cb8067..298b704bde 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -860,9 +860,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise @@ -926,10 +926,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * Fork one cold-readable exact event prefix into a new Session. An omitted * boundary selects the latest completed-turn prefix; an open cut receives * synthetic fork closers. - * @param request - source Session and optional exact inclusive event boundary. + * @param request - source Session, optional exact inclusive event boundary, and migration preflight choice. * @returns the new Session identity. */ -@Remote('fork') fork(request: SessionForkRequest): Promise +@Remote('fork') async fork(request: SessionForkRequest): Promise /** * Admit one prompt after explicitly resuming its Session. @@ -978,10 +978,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** - * Read all registered projections without activating an Agent. + * Read exact projections, returning cached hints only when migration is required. * @param request - Session whose current values are required. - * @param signal - cancellation for the Session observation. - * @returns complete baseline, or null when the Session does not exist. + * @param signal - cancellation for the Session read. + * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ @Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 385b99e972..83cd761e68 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -12,8 +12,8 @@ en: 465f0c330fc1054b zh: bd0b68a05790fff8 /right-sidebar/tab-type-registration: - en: 1e01c817c5879c40 - zh: a79abd92ee4c8f85 + en: b7b8a7b411aee94d + zh: 7298c20000945d97 /right-sidebar/navigation-ctx-sidebarright: en: 0f80b5364cbad804 zh: db81652a5374fad1 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 6315827c27..1342a7f938 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -46,6 +46,8 @@ Tab identity is the pair `(kind, address)`: the registry's claim uses the addres | `keepMounted` | Optional lazy retention of visited bodies across hiding, Session switches and docking; the owning View holds the Session reference. | | `guide` | Optional entry boxes for the guide page: `{ order, title(), description?(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. | +Whole-address patterns match the raw address: picomatch globstar (`**`) excludes standalone `.` and `..` segments even with `dot: true`, which includes dotfiles but does not change that exclusion. Resource-specific code parses addresses; the Host enforces file-read permissions. + Routing is a ranked claim. `candidates(address)` ranks the types whose patterns match and whose `canOpen` does not veto: by band, then by the length of the longest matched pattern, then by registration order. `claim(address, kind?)` picks the first candidate, or the named `kind` outright — its globs are skipped, its `canOpen` still applies — and returns `{ kind, contentId: address, title }`. An address no type claims throws: it is a wiring mistake, not a user error. One `kind` may carry one `builtin` and one `extension` registration at the same time. The extension is the one in force for claims, `get(kind)`, `openTab(kind)`, and the guide page, and the seat finds a tab's body and title under the definition in force's `id`, so no slot priority is involved; when the extension unregisters, the builtin resumes. Every other collision on a kind, and every duplicate `id`, throws. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 1df6007e12..e7df97d11c 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -46,6 +46,8 @@ tab 身份是 `(kind, address)` 二元组:注册表的认领把地址原文用 | `keepMounted` | 可选的懒保活:已访问正文跨隐藏、Session 切换与停靠切换保留,由所属 View 持有 Session reference。 | | `guide` | 可选的引导页入口框:`{ order, title(), description?(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 | +完整地址模式匹配地址原文:picomatch 的 globstar(`**`)即使启用 `dot: true` 也排除独立的 `.` 和 `..` 段;该选项允许匹配 dotfile,不改变点段排除规则。地址由资源业务代码解析,文件读取仍受 Host 文件系统权限限制。 + 路由是一次排序认领。`candidates(address)` 对模式命中且未被 `canOpen` 否决的类型排序:先按档,再按最长命中模式的长度,最后按注册顺序。`claim(address, kind?)` 取第一个候选,或直接用点名的 `kind`——跳过它的 glob,但 `canOpen` 仍生效——返回 `{ kind, contentId: address, title }`。没有任何类型认领的地址会抛错:这是接线错误,不是用户错误。 同一个 `kind` 可同时携带一个 `builtin` 与一个 `extension` 注册。extension 在认领、`get(kind)`、`openTab(kind)` 与引导页上生效,席位按生效定义的 `id` 找 tab 的正文与标题,不涉及任何 slot 优先级;extension 注销后 builtin 恢复。kind 上的其它任何撞名以及任何重复的 `id` 都抛错。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 67850a5a7d..1dd04a5ab9 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -10,7 +10,7 @@ zh: 441ed13ff3f81d2a /web-client-slots/cardinality-and-scope: en: 5b0ada069239576b - zh: 7e71ced7f17d78a6 + zh: 84e1e6b253d1461e /web-client-slots/component-inputs: en: 9cb70abb2e39b958 zh: cd4b6f1705d90e17 @@ -18,11 +18,11 @@ en: 2fcfc55fbac80f72 zh: ea3016f394c56b07 /web-client-slots/developer-provided-injection: - en: 7081335b356bf46b - zh: 631e60d29daf7196 -/web-client-slots/current-hierarchy: - en: 8a4cc4b8608ce0f6 - zh: 21281dcf047503b2 + en: 3481dfa6cd3097da + zh: acbd6f06a7da1752 +/web-client-slots/conversation-hierarchy: + en: e1d572c42e91d663 + zh: 5a649dea2682be7f /web-client-slots/extension-rules: en: b2cce357141883ee zh: 278080a5fb941189 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 8a9448a4f8..30f8acedb4 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -106,95 +106,86 @@ A reserved `hooks` object in that return value accepts bare `getSnapshot`/`subsc The owner of a slot may put an `inject` face in the child declaration when every occupant needs the same capability. Plain members reach all occupants unchanged. Function-valued members inside its `hooks` object are hook factories; they receive the slot's standard props and optional per-render `hookContext`, then return the constrained hook exposed to the occupant. `conversation.chat.node` uses this mechanism to provide `useTurnData(key)` for the node currently being rendered. -The session-scoped `conversation.chat.reasoning-body` chain receives unchanged `text` and `running` values for an expanded reasoning body. Its owner renders original compact Markdown when all entries decline; display contributions retain access to the original reasoning. - Use owner props for values already known at one render occurrence, registration `inject` for one entry's callbacks and private observables, slot-level `inject` for a capability controlled by the slot owner, and a declared store for mutable view state shared across entries or preserved across remounts. React nodes compose through child slots, not through injected values. -## Current hierarchy + -The hierarchy below is the shipped declaration tree. A child exists only while the named parent entry is mounted; optional feature entries can therefore make a subtree appear or disappear as one lifecycle unit. +## Conversation hierarchy + +The following Conversation paths distinguish declaration owners from reusable Factory roots; unrelated shell positions are omitted. An entry owns its declared children, while a Factory owns its child declarations for the lifetime of its registered definition. Bracketed names identify the default React components; a plain React descendant can render its owner's Slot. Factory calls are references to independent reusable definitions, not new Slot declarations. ```text -root -├─ sidebar -│ ├─ sidebar.brand.mark -│ ├─ sidebar.brand.name -│ ├─ sidebar.panellist -│ ├─ sidebar.footer.action -│ ├─ sidebar.workspaces -│ │ ├─ sidebar.workspaces.directoryFlow -│ │ ├─ sidebar.workspaces.session.menu.item -│ │ └─ sidebar.workspaces.session.row.action -│ └─ sidebar.settings -│ ├─ settings.trigger -│ ├─ settings.header -│ ├─ settings.action -│ ├─ settings.close -│ ├─ settings.onboarding -│ └─ settings.section -│ ├─ settings.general.item -│ ├─ settings.models.provider-card -│ ├─ settings.models.footer -│ └─ settings.plugins.tab -├─ main -│ ├─ plugins.add.actions -│ ├─ plugins.item -│ ├─ plugins.bundle.config -│ ├─ plugins.row.config -│ ├─ plugins.detail.actions -│ ├─ plugins.detail.badge -│ ├─ plugins.detail.section -│ └─ main.conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.node -│ │ │ ├─ conversation.chat.assistant-actions -│ │ │ ├─ conversation.chat.reasoning-body -│ │ │ ├─ conversation.chat.commandview -│ │ │ ├─ conversation.chat.turnTail -│ │ │ └─ tool.call.toolview -│ │ │ ├─ tool.call.images -│ │ │ └─ tool.view.cordis -│ │ ├─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.header -│ │ ├─ conversation.header.leading -│ │ └─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ ├─ conversation.approval.detail -│ │ └─ conversation.plan-review.actions -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.permission -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset -├─ rightbar -│ └─ rightbar.session -│ ├─ sidebar.right.pane.tab -│ │ ├─ sidebar.right.tab.guide -│ │ └─ sidebar.right.tab.guide.entry -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item -├─ shell.bottom -├─ shell.leading -└─ shell.overlay - └─ shell.quota-notice +root [AppFrame] +└─ main[key=conversation] [ConversationPanel] + └─ main.conversation [ConversationRoot / ConversationMainPanel] + ├─ conversation.header [ConversationHeader] + │ ├─ conversation.header.leading + │ └─ conversation.session.header [ConversationSessionHeader] + │ ├─ conversation.session.header.lineage + │ ├─ conversation.session.header.actions + │ ├─ conversation.session.header.utilities + │ └─ conversation.session.header.corner + └─ calls Factory: conversation.content + +Factory: conversation.content [ConversationContent] +├─ conversation.session [ConversationSession / DefaultConversationViews] +│ └─ conversation.view +│ ├─ id=chat [ChatView] +│ │ └─ conversation.chat.flow [ChatFlow] +│ │ └─ conversation.chat.node [ChatNodeSeat dispatch] +│ │ ├─ key=user,steering [UserMessageNodeView] +│ │ │ └─ calls Factory: conversation.chat.user.content +│ │ ├─ key=assistant-step [AssistantNodeView / AssistantMarkdown] +│ │ │ ├─ conversation.chat.message.images [MessageImages] +│ │ │ └─ conversation.chat.reasoning.body [DefaultReasoningBody] +│ │ │ └─ calls Factory: conversation.chat.reasoning.content +│ │ ├─ key=tool-call [ToolCallTree] +│ │ │ └─ tool.call.toolview +│ │ │ └─ key=read_image [ReadImageRow / ToolRow] +│ │ │ └─ tool.call.images [MessageImages] +│ │ ├─ key=command-input [GoalCommandInputView] +│ │ │ └─ calls Factory: conversation.user.text +│ │ ├─ key=command [CommandNodeView] +│ │ │ └─ conversation.chat.commandview +│ │ └─ key=turn-tail [TurnTailNodeView] +│ │ ├─ conversation.chat.turnTail +│ │ └─ conversation.chat.assistant-actions +│ └─ id=trajectory [TrajectoryView / TrajectoryTable] +│ └─ conversation.trajectory.images [MessageImages] +├─ conversation.composer +│ ├─ conversation.approval.detail +│ └─ conversation.plan-review.actions +├─ conversation.composer.bar [InputBar] +│ ├─ conversation.input.attachments +│ ├─ conversation.input.permission +│ ├─ conversation.input.plan +│ ├─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ └─ conversation.input.right +├─ conversation.input.dock +│ └─ id=queue [QueueDock] +│ └─ calls Factory: conversation.user.text +├─ conversation.hero.brand.mark +├─ conversation.hero.workspace +│ └─ conversation.hero.workspace.directoryFlow +└─ conversation.hero.agentPreset + +Factory: conversation.chat.user.content [UserContent] +├─ conversation.chat.user.images [MessageImages] +└─ calls Factory: conversation.user.text + +Factory: conversation.user.text [UserText] +└─ conversation.user.text.node [keyed, inline] + +Factory: conversation.images.content [MessageImagesContent / ImageGallery] +Factory: conversation.chat.reasoning.content [ReasoningContent / MarkdownText] ``` -The generated Client inspect catalog is the exhaustive contract for each key: cardinality, scope, owner props, standard props, current occupants, declaration owner, and replacement risk. A running dynamic package can query the live tree and an exact key with `cordis_inspect what:"client"`; the source catalog is generated from `SlotMap` declarations and `slots.register()` call sites by `pnpm run gen-client-catalog`. +All four image Slots default to `MessageImages`, which calls `conversation.images.content` with the caller's image references and authorized loader. User and output image replacements are independent. The `conversation.user.text` Factory is shared by user content, Queue, and Goal; its `inline: true` keyed Slot renders span anchors and inline error placeholders rather than block wrappers. + +The generated [Client inspect catalog](../../packages/extensions/cordis-client-runner/src/client/slot-catalog.ts) covers all shell branches and is the exhaustive contract for each key: cardinality, scope, owner props, standard props, current occupants, declaration owner, and replacement risk. A running dynamic package can query the live tree and an exact key with `cordis_inspect what:"client"`; the source catalog is generated from `SlotMap` declarations and `slots.register()` call sites by `pnpm run gen-client-catalog`. ## Extension rules diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index bf8bc0cac7..a660f720e4 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -61,6 +61,8 @@ Slot 声明固定两个相互独立的维度。 对于 `single`、`list` 和 `keyed` cell,`priority` 是遮蔽优先级;对于 `chain`,它是选举顺序。数值越小越先运行或渲染。普通增量贡献应选用新的 list `id` 或 keyed `key`;复用已有 cell 表示有意替换其展示。 + + ## 组件输入 注册组件会在 binding 位置收到组装后的输入。组件应从这些类型推导 props,不要重新抄写成员。 @@ -106,95 +108,88 @@ Renderer 还会根据声明的 store 创建 `useStore`,并根据声明的 loca 当每个 occupant 都需要同一种能力时,slot owner 可以在 child 声明里放置 `inject` face。普通成员会原样交给所有 occupant;其 `hooks` 对象中的函数成员是 hook factory,它会收到 slot 的标准 props 与可选的逐次渲染 `hookContext`,再返回提供给 occupant 的受限 hook。`conversation.chat.node` 正是通过这种机制,为当前渲染的 node 提供 `useTurnData(key)`。 -Session 作用域的 `conversation.chat.reasoning-body` 链为展开的思考正文提供未修改的 `text` 和 `running`。所有条目都拒绝认领时,owner 渲染原始紧凑 Markdown;显示贡献保留查看原始思考内容的能力。 - 一次渲染时 owner 已知的值走 owner props;单个 entry 的 callback 与私有 observable 走注册项 `inject`;由 slot owner 控制、所有 occupant 共享的能力走 slot 级 `inject`;需要跨 entry 共享或跨重新挂载保留的可变视图状态走声明的 store。React node 通过 child slot 组合,不通过注入值传递。 -## 当前层级 + -下图是当前发布组合的声明树。只有具名 parent entry 已挂载时,其 child 才存在;因此可选功能 entry 可以作为一个生命周期单元让整棵子树出现或消失。 +## Conversation 层级 + +下面的 Conversation 路径区分声明所有者与可复用 Factory 根,省略无关的外壳位置。条目拥有其声明的子 Slot;Factory 在其定义注册期间拥有子 Slot 声明。方括号标出默认 React 组件,普通 React 后代组件可以渲染其所有者的 Slot。Factory 调用指向独立的可复用定义,不是新的 Slot 声明。 ```text -root -├─ sidebar -│ ├─ sidebar.brand.mark -│ ├─ sidebar.brand.name -│ ├─ sidebar.panellist -│ ├─ sidebar.footer.action -│ ├─ sidebar.workspaces -│ │ ├─ sidebar.workspaces.directoryFlow -│ │ ├─ sidebar.workspaces.session.menu.item -│ │ └─ sidebar.workspaces.session.row.action -│ └─ sidebar.settings -│ ├─ settings.trigger -│ ├─ settings.header -│ ├─ settings.action -│ ├─ settings.close -│ ├─ settings.onboarding -│ └─ settings.section -│ ├─ settings.general.item -│ ├─ settings.models.provider-card -│ ├─ settings.models.footer -│ └─ settings.plugins.tab -├─ main -│ ├─ plugins.add.actions -│ ├─ plugins.item -│ ├─ plugins.bundle.config -│ ├─ plugins.row.config -│ ├─ plugins.detail.actions -│ ├─ plugins.detail.badge -│ ├─ plugins.detail.section -│ └─ main.conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.node -│ │ │ ├─ conversation.chat.assistant-actions -│ │ │ ├─ conversation.chat.reasoning-body -│ │ │ ├─ conversation.chat.commandview -│ │ │ ├─ conversation.chat.turnTail -│ │ │ └─ tool.call.toolview -│ │ │ ├─ tool.call.images -│ │ │ └─ tool.view.cordis -│ │ ├─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.header -│ │ ├─ conversation.header.leading -│ │ └─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ ├─ conversation.approval.detail -│ │ └─ conversation.plan-review.actions -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.permission -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset -├─ rightbar -│ └─ rightbar.session -│ ├─ sidebar.right.pane.tab -│ │ ├─ sidebar.right.tab.guide -│ │ └─ sidebar.right.tab.guide.entry -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item -├─ shell.bottom -├─ shell.leading -└─ shell.overlay - └─ shell.quota-notice +root [AppFrame] +└─ main[key=conversation] [ConversationPanel] + └─ main.conversation [ConversationRoot / ConversationMainPanel] + ├─ conversation.header [ConversationHeader] + │ ├─ conversation.header.leading + │ └─ conversation.session.header [ConversationSessionHeader] + │ ├─ conversation.session.header.lineage + │ ├─ conversation.session.header.actions + │ ├─ conversation.session.header.utilities + │ └─ conversation.session.header.corner + └─ calls Factory: conversation.content + +Factory: conversation.content [ConversationContent] +├─ conversation.session [ConversationSession / DefaultConversationViews] +│ └─ conversation.view +│ ├─ id=chat [ChatView] +│ │ └─ conversation.chat.flow [ChatFlow] +│ │ └─ conversation.chat.node [ChatNodeSeat dispatch] +│ │ ├─ key=user,steering [UserMessageNodeView] +│ │ │ └─ calls Factory: conversation.chat.user.content +│ │ ├─ key=assistant-step [AssistantNodeView / AssistantMarkdown] +│ │ │ ├─ conversation.chat.message.images [MessageImages] +│ │ │ └─ conversation.chat.reasoning.body [DefaultReasoningBody] +│ │ │ └─ calls Factory: conversation.chat.reasoning.content +│ │ ├─ key=tool-call [ToolCallTree] +│ │ │ └─ tool.call.toolview +│ │ │ └─ key=read_image [ReadImageRow / ToolRow] +│ │ │ └─ tool.call.images [MessageImages] +│ │ ├─ key=command-input [GoalCommandInputView] +│ │ │ └─ calls Factory: conversation.user.text +│ │ ├─ key=command [CommandNodeView] +│ │ │ └─ conversation.chat.commandview +│ │ └─ key=turn-tail [TurnTailNodeView] +│ │ ├─ conversation.chat.turnTail +│ │ └─ conversation.chat.assistant-actions +│ └─ id=trajectory [TrajectoryView / TrajectoryTable] +│ └─ conversation.trajectory.images [MessageImages] +├─ conversation.composer +│ ├─ conversation.approval.detail +│ └─ conversation.plan-review.actions +├─ conversation.composer.bar [InputBar] +│ ├─ conversation.input.attachments +│ ├─ conversation.input.permission +│ ├─ conversation.input.plan +│ ├─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ └─ conversation.input.right +├─ conversation.input.dock +│ └─ id=queue [QueueDock] +│ └─ calls Factory: conversation.user.text +├─ conversation.hero.brand.mark +├─ conversation.hero.workspace +│ └─ conversation.hero.workspace.directoryFlow +└─ conversation.hero.agentPreset + +Factory: conversation.chat.user.content [UserContent] +├─ conversation.chat.user.images [MessageImages] +└─ calls Factory: conversation.user.text + +Factory: conversation.user.text [UserText] +└─ conversation.user.text.node [keyed, inline] + +Factory: conversation.images.content [MessageImagesContent / ImageGallery] +Factory: conversation.chat.reasoning.content [ReasoningContent / MarkdownText] ``` -生成的 Client inspect catalog 是每个 key 的完整参考,包含 cardinality、scope、owner props、标准 props、当前 occupant、声明 owner 与替换风险。运行中的动态包可以用 `cordis_inspect what:"client"` 查询实时树与某个精确 key;源码 catalog 由 `pnpm run gen-client-catalog` 根据 `SlotMap` 声明和 `slots.register()` 调用点生成。 +四个图片 Slot 默认均填入 `MessageImages`,由它携带调用者的图片引用和授权 loader 调用 `conversation.images.content`。用户与输出图片的替换互不影响。`conversation.user.text` Factory 由用户内容、Queue 和 Goal 共用;其 `inline: true` keyed Slot 使用 span 锚点及行内错误占位,而不是块级包装。 + +生成的 [Client inspect catalog](../../packages/extensions/cordis-client-runner/src/client/slot-catalog.ts) 是包含所有外壳分支及每个 key 的完整参考,包含 cardinality、scope、owner props、标准 props、当前 occupant、声明 owner 与替换风险。运行中的动态包可以用 `cordis_inspect what:"client"` 查询实时树与某个精确 key;源码 catalog 由 `pnpm run gen-client-catalog` 根据 `SlotMap` 声明和 `slots.register()` 调用点生成。 + + ## 扩展规则 diff --git a/docs/subsystems/ssh.i18n.yaml b/docs/subsystems/ssh.i18n.yaml index 1451fd22a2..d432de67ba 100644 --- a/docs/subsystems/ssh.i18n.yaml +++ b/docs/subsystems/ssh.i18n.yaml @@ -6,8 +6,8 @@ en: 847a37e7a77c8a3f zh: 33f4de7837ec2faa /ssh/execution-coordinates: - en: 77968e8be38ab9be - zh: f5bf0f62b32e45c7 + en: 6dca62a58ecbc1ff + zh: 1ebb7dbe79927701 /ssh/transport-and-trust: en: e807ecd0222acb80 zh: b2077393b549a7fe diff --git a/docs/subsystems/ssh.md b/docs/subsystems/ssh.md index 04480e6ec3..440e42c26d 100644 --- a/docs/subsystems/ssh.md +++ b/docs/subsystems/ssh.md @@ -8,7 +8,7 @@ The [SSH provider family](../../packages/ssh/README.md) supplies one remote file Filesystem identities, executable lookup, process cwd, sandbox workspace roots and language-server file URLs refer to the SSH host. Providers canonicalize paths where the files exist, preserving filesystem interpretation of `symlink/..`. The policy resolver carries absolute execution-world spelling without trying to resolve remote paths on the Harness host. -`processPath()` supplies a path usable by the paired subprocess provider. `processPathFromHostPath()` remains unavailable for SSH; installing a remote artifact does not make an arbitrary host path portable. [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.md) therefore takes an explicitly installed, digest-verified remote bootstrap. +`processPath()` supplies a path usable by the paired subprocess provider. `processPathFromHostPath()` remains unavailable for SSH; installing a remote artifact does not make an arbitrary host path portable. [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.md) therefore takes the verified remote launch configuration: an installed script bootstrap or the [helper executable’s embedded worker](../../packages/ssh/ssh-helper-runtime/README.md). ## Transport and trust @@ -30,23 +30,36 @@ See the [decision record](../../.agents/notes/implemented/architecture/2026-09-1 ## Connection API +```ts type-equiv +/** Installed helper invocation; script deployments may also install a PTC bootstrap. */ +type HelperLaunch = { + /** Run the installed script with a separately installed Node executable. */ + kind: 'node-script' + /** Absolute remote Node executable. */ + node: string + /** Absolute remote PTC bootstrap; requires bootstrapHash. */ + bootstrapPath?: string + /** Lowercase SHA-256 of bootstrapPath; requires that path. */ + bootstrapHash?: string +} | { + /** Run the helper executable with its embedded Node and PTC worker. */ + kind: 'executable' +} +``` + ```ts type-equiv /** Deployment-owned SSH identity and installed helper; no model argument selects these values. */ interface Config { /** OpenSSH host alias, including its existing user, key and known-host configuration. */ host: string - /** Absolute remote Node executable. */ - node: string + /** Explicit script or self-contained executable invocation. */ + launch: HelperLaunch /** Absolute path to the installed, bundled helper entry. */ helper: string /** SHA-256 of that bundled helper; mismatches refuse the connection. */ helperHash: string /** Absolute remote default workspace. */ workspace: string - /** Optional preinstalled built PTC entry, paired with its expected digest. */ - bootstrapPath?: string - /** SHA-256 of bootstrapPath; both fields must be supplied together. */ - bootstrapHash?: string /** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */ requestTimeoutMs?: number /** Maximum JSON payload bytes per helper request or response. */ @@ -67,10 +80,8 @@ declare class SshConnection extends Service { constructor(ctx: Context, config: Config); /** Hold plugin readiness until the remote identity and helper digest are verified. */ async [Service.init](): Promise; - /** Verified remote Node executable for the paired PTC runtime. */ - get nodeExecutable(): string; - /** Verified preinstalled PTC entry; unconfigured runtimes fail before program execution. */ - get bootstrapPath(): string; + /** Verified remote PTC launch configuration; script deployments require a verified bootstrap. */ + get ptcLaunch(): { kind: 'embedded'; executable: string } | { kind: 'node-script'; executable: string; bootstrapPath: string }; /** * Send a helper operation; cancellation never replays an ambiguous mutation. * @param method - the private helper operation. diff --git a/docs/subsystems/ssh.zh.md b/docs/subsystems/ssh.zh.md index 32d7c8b6c2..862d8402a1 100644 --- a/docs/subsystems/ssh.zh.md +++ b/docs/subsystems/ssh.zh.md @@ -8,7 +8,7 @@ 文件系统身份、可执行文件查找、进程 cwd、沙箱工作区根目录及语言服务器文件 URL 都指向 SSH 主机。提供方在文件实际存在的位置规范化路径,保留文件系统对 `symlink/..` 的解释。策略解析器保留执行环境中的绝对路径写法,不尝试在 Harness 主机上解析远端路径。 -`processPath()` 提供配套子进程提供方可用的路径。SSH 的 `processPathFromHostPath()` 仍不可用;安装远端产物不意味着任意主机路径可移植。因此 [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md) 使用显式安装并经过摘要验证的远端引导程序。 +`processPath()` 提供配套子进程提供方可用的路径。SSH 的 `processPathFromHostPath()` 仍不可用;安装远端产物不意味着任意主机路径可移植。因此 [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md) 使用验证后的远端启动配置:已安装的脚本引导程序,或 [helper 可执行文件内嵌的 worker](../../packages/ssh/ssh-helper-runtime/README.zh.md)。 ## 传输与信任 @@ -30,23 +30,36 @@ headless 通过已挂载的文件系统提供方记录和检查 Session cwd。 ## 连接 API +```ts type-equiv +/** Installed helper invocation; script deployments may also install a PTC bootstrap. */ +type HelperLaunch = { + /** Run the installed script with a separately installed Node executable. */ + kind: 'node-script' + /** Absolute remote Node executable. */ + node: string + /** Absolute remote PTC bootstrap; requires bootstrapHash. */ + bootstrapPath?: string + /** Lowercase SHA-256 of bootstrapPath; requires that path. */ + bootstrapHash?: string +} | { + /** Run the helper executable with its embedded Node and PTC worker. */ + kind: 'executable' +} +``` + ```ts type-equiv /** Deployment-owned SSH identity and installed helper; no model argument selects these values. */ interface Config { /** OpenSSH host alias, including its existing user, key and known-host configuration. */ host: string - /** Absolute remote Node executable. */ - node: string + /** Explicit script or self-contained executable invocation. */ + launch: HelperLaunch /** Absolute path to the installed, bundled helper entry. */ helper: string /** SHA-256 of that bundled helper; mismatches refuse the connection. */ helperHash: string /** Absolute remote default workspace. */ workspace: string - /** Optional preinstalled built PTC entry, paired with its expected digest. */ - bootstrapPath?: string - /** SHA-256 of bootstrapPath; both fields must be supplied together. */ - bootstrapHash?: string /** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */ requestTimeoutMs?: number /** Maximum JSON payload bytes per helper request or response. */ @@ -67,10 +80,8 @@ declare class SshConnection extends Service { constructor(ctx: Context, config: Config); /** Hold plugin readiness until the remote identity and helper digest are verified. */ async [Service.init](): Promise; - /** Verified remote Node executable for the paired PTC runtime. */ - get nodeExecutable(): string; - /** Verified preinstalled PTC entry; unconfigured runtimes fail before program execution. */ - get bootstrapPath(): string; + /** Verified remote PTC launch configuration; script deployments require a verified bootstrap. */ + get ptcLaunch(): { kind: 'embedded'; executable: string } | { kind: 'node-script'; executable: string; bootstrapPath: string }; /** * Send a helper operation; cancellation never replays an ambiguous mutation. * @param method - the private helper operation. diff --git a/docs/subsystems/translation.i18n.yaml b/docs/subsystems/translation.i18n.yaml index a6f25d640c..51904d7464 100644 --- a/docs/subsystems/translation.i18n.yaml +++ b/docs/subsystems/translation.i18n.yaml @@ -15,5 +15,5 @@ en: 40d175fbe66fd4cd zh: bff18511a8d386e9 /text-translation/reasoning-display: - en: 209dbbbf3000e087 - zh: f8e661359d0f3cb5 + en: aaf7d361b682fe4a + zh: 485e89bdb063665e diff --git a/docs/subsystems/translation.md b/docs/subsystems/translation.md index c5de82676f..8cbf6d3ac8 100644 --- a/docs/subsystems/translation.md +++ b/docs/subsystems/translation.md @@ -30,7 +30,7 @@ The [optional bundle](../../packages/experimental/cot-translation-bundle/README. `CotTranslationPreferences` contains the accepted provider and target language. `CotTranslationSnapshot` pairs these preferences with the translator's current `maxTextChars` and eligible provider IDs. The browser reads this Host snapshot before registering translation controls, including when local settings storage is unavailable. Accepted settings updates and reconnects refresh both values; a changed request limit cancels and splits the expanded text again. -The session-scoped `conversation.chat.reasoning-body` chain receives the original `text` and `running` flag. Chat renders original Markdown when no contribution claims it. While enabled, the GUI translates completed paragraphs and request-sized prefixes of long streaming paragraphs only after expansion, and restores completed results from the current Session. Its local cache only optimizes that disclosure. The unfinished tail remains original. Original remains accessible. Closing the disclosure, switching provider or language, and unloading cancel outstanding calls and exclude late results. Provider failure preserves the original and requires an explicit retry; translated text never replaces original Session events or model input. +The GUI registers in the session-scoped `conversation.chat.reasoning.body` single Slot, replacing Chat's default entry. Both entries reuse the official `conversation.chat.reasoning.content` Factory; the translation wrapper selects display text, owns its toolbar and view state, and supplies its localized labels without duplicating Markdown rendering. While enabled, the GUI translates completed paragraphs and request-sized prefixes of long streaming paragraphs only after expansion, and restores completed results from the current Session. Its local cache only optimizes that disclosure. The unfinished tail remains original. Original remains accessible. Closing the disclosure, switching provider or language, and unloading cancel outstanding calls and exclude late results. Provider failure preserves the original and requires an explicit retry; translated text never replaces original Session events or model input. diff --git a/docs/subsystems/translation.zh.md b/docs/subsystems/translation.zh.md index e3700edcbd..4600d369e4 100644 --- a/docs/subsystems/translation.zh.md +++ b/docs/subsystems/translation.zh.md @@ -30,7 +30,7 @@ Google 在 form POST 请求体中接收原文,Bing 在 JSON POST 请求体中 `CotTranslationPreferences` 包含已接受的翻译服务和目标语言。`CotTranslationSnapshot` 将这些偏好与 translator 当前的 `maxTextChars` 及可用服务 ID 一起返回。浏览器在注册翻译控件前读取此 Host 快照,本地设置存储不可用时也如此。接受的设置更新及重新连接会刷新这两项值;请求长度限制变化时,展开内容的请求被取消并重新分片。 -Session 作用域的 `conversation.chat.reasoning-body` chain 接收原始 `text` 和 `running` 标志。没有 contribution 接管时,Chat 渲染原始 Markdown。启用后,GUI 只在展开后翻译完整段落和流式长段落中达到请求长度的前缀,并从当前 Session 恢复已完成结果;本地缓存只优化当前展开区域。未完成的末尾片段保留原文。原文始终可访问。关闭展开内容、切换 Provider 或语言、卸载插件都会取消未完成调用并忽略迟到的结果。Provider 失败时保留原文,必须显式重试;译文从不替换原始 Session 事件或模型输入。 +GUI 向 Session 作用域的 `conversation.chat.reasoning.body` single Slot 注册,替换 Chat 的默认条目。两个条目均复用官方 `conversation.chat.reasoning.content` Factory;翻译包装选择显示文本,自行维护工具栏和视图状态,传入自己的本地化 labels,不重复实现 Markdown 渲染。启用后,GUI 只在展开后翻译完整段落和流式长段落中达到请求长度的前缀,并从当前 Session 恢复已完成结果;本地缓存只优化当前展开区域。未完成的末尾片段保留原文。原文始终可访问。关闭展开内容、切换 Provider 或语言、卸载插件都会取消未完成调用并忽略迟到的结果。Provider 失败时保留原文,必须显式重试;译文从不替换原始 Session 事件或模型输入。 diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index c2e4d0bec6..9a9d78794a 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-server.md /http-server: - en: 778b8499fa5ec417 - zh: f2073db3d2ed8b70 + en: c9b2f67d3248c496 + zh: 5fa36d2837ff4f63 /http-server/routes: - en: 940bc78642c990b0 - zh: 901d2ba7e354a766 + en: e949c3738a35b58f + zh: 138e3aaeb319ddc4 /http-server/config: - en: 30118110a0ea3702 - zh: 940f2b69d2dd54af + en: 4b162bd9f280eb06 + zh: 3bea98dfee88c582 /http-server/the-service: - en: e304734ab2143438 - zh: 065f9c2acbbb07ca + en: cc36972b8458279f + zh: c129044935d44769 diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 2826bbe9d2..4fcb42f3cc 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -2,7 +2,7 @@ English | [中文](web-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -26,15 +26,34 @@ interface WebRoute { Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts), the SPA dist server with locked semantics: Connection authenticates the dist root and configured index before their HTML is read; non-index assets remain public; non-GET/HEAD is 405, traversal outside the dist root is 403, existing files are served directly, absent or non-file targets are empty 404 responses, and unknown extensions ship as octet-stream. + + ## Config ```ts type-equiv -/** Web server listen and response-compression config. */ +/** Web server listen, TLS, and response-compression config. */ interface Config { - /** Listen host; the two supported values are loopback and all-interfaces. */ - host: '127.0.0.1' | '0.0.0.0' + /** + * Listen address: a concrete IPv4 or IPv6 literal of one local interface, + * for example the container's own Pod address from `hostname -i`. A loopback + * literal (any address in 127/8, `::1`, or a mapped form of either) keeps the + * server on this machine; any other literal serves the network that address + * belongs to, over plain HTTP unless `tls` is set. The unspecified address — + * IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at + * load: it would expose the port on every interface at once. + */ + host: string /** Listen port; zero requests an OS-assigned port. */ port: number + /** + * Serve HTTPS with this certificate and key instead of plain HTTP. Both files + * are read once, before the listener binds: an unreadable or empty file, + * invalid PEM, or a key that does not match the certificate rejects + * initialization rather than falling back to HTTP. The material is never + * re-read, so replacing a certificate takes a reload. Omitted or null listens + * over plain HTTP. + */ + tls?: TlsConfig /** Response compression for socket-backed HTTP requests. @default 'none' */ compression?: 'none' | 'gzip' /** Gzip DEFLATE level from 0 through 9. @default 1 */ @@ -44,13 +63,27 @@ interface Config { } ``` -`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). The carrier itself owns no TLS, authentication, or Origin policy, so a non-loopback bind exposes the server unless the composition supplies those controls. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The shipped `dsh web` command selects loopback and rejects `--host 0.0.0.0`; its Connection plugin supplies Host/Origin checks plus browser-session authentication for every Host API route and stream. Other compositions own their bind and route-authentication policy. The dist location is an assembly fact of the frontend plugin that claims the seat. +```ts type-equiv +/** + * TLS material for the HTTPS listener: one certificate chain file and the + * private key file it pairs with. Both hold PEM text and both resolve against + * the process working directory. + */ +interface TlsConfig { + /** Certificate chain file, leaf certificate first, PEM, no passphrase. */ + certFile: string + /** Private key file for the chain's leaf certificate; unencrypted PEM. */ + keyFile: string +} +``` + +No composition can bind every interface at once: the unspecified address is rejected at load. The exported `isWildcardHost` and `isLoopbackHost` classify a bind address from its parsed value, and an IPv6 `%zone` is kept for `listen` while classification reads the address alone. The carrier owns no authentication or Origin policy, and it serves plain HTTP unless `tls` supplies a certificate and key. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The shipped `dsh web` command selects loopback by default and accepts one concrete `--host` address, warning at startup when a non-loopback bind serves plain HTTP; its Connection plugin supplies Host/Origin checks plus browser-session authentication for every Host API route and stream, and the bind address plus `--trusted-host` govern which browser Host is accepted; `--public-url` changes only the advertised URL. Other compositions own their bind and route-authentication policy. The dist location is an assembly fact of the frontend plugin that claims the seat. ## The service -`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0. +`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`). -A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md). +A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. A TLS socket counts as a connection only after its handshake, so the server tracks raw TLS sockets beside upgraded ones and destroys both at disposal. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md). @@ -92,6 +125,8 @@ admit(request: ConnectionTrustRequest): PeerAdmission /** * Authenticate one frontend index request, owning a token redirect or 401. + * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, + * which only that listener's protocol decides. * @param request - root or configured-index HTTP request. * @param response - response owned when the result is false. * @returns true only when the frontend may serve index.html. @@ -108,13 +143,37 @@ authenticatedUrl(baseUrl: string): string Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) + + +### `ctx.webApp` — `WebApp` + +The Web GUI this process serves, provided by the `web-runtime` row. + +```ts cordis-catalog +/** + * The URL this GUI advertises, also used for `DSH_WEB_URL` and the startup URL line. + * @returns the configured `publicUrl`, otherwise the listener origin. + */ +getAdvertisedUrl(): string +``` + +Source: [`packages/bundle/web-app/src/index.ts`](../../packages/bundle/web-app/src/index.ts) + ### `ctx.webServer` — `WebServer` -The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber. +The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. ```ts cordis-catalog +/** + * The root URL local clients use to reach the bound listener: the protocol, + * the {@link bindUrlHost} form of the bind address, and the listening port. + * @returns the origin, for example `http://127.0.0.1:3080`. + * @throws before `listen` binds a port, or when the bind address has no URL form. + */ +getOrigin(): string + /** * Register a named route. Duplicate (kind, path) throws — route patterns are * a composition-level contract, so a collision is a misconfiguration. diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index 72ef366958..67d67b15de 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -2,7 +2,7 @@ [English](web-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -26,15 +26,34 @@ interface WebRoute { 匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:Connection 在读取 dist 根目录和配置 index 的 HTML 前完成认证;非 index 资产保持公开;非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,现有文件直接提供,缺失或不是文件的目标返回空的 404,未知扩展名按 octet-stream 发送。 + + ## 配置 ```ts type-equiv -/** Web server listen and response-compression config. */ +/** Web server listen, TLS, and response-compression config. */ interface Config { - /** Listen host; the two supported values are loopback and all-interfaces. */ - host: '127.0.0.1' | '0.0.0.0' + /** + * Listen address: a concrete IPv4 or IPv6 literal of one local interface, + * for example the container's own Pod address from `hostname -i`. A loopback + * literal (any address in 127/8, `::1`, or a mapped form of either) keeps the + * server on this machine; any other literal serves the network that address + * belongs to, over plain HTTP unless `tls` is set. The unspecified address — + * IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at + * load: it would expose the port on every interface at once. + */ + host: string /** Listen port; zero requests an OS-assigned port. */ port: number + /** + * Serve HTTPS with this certificate and key instead of plain HTTP. Both files + * are read once, before the listener binds: an unreadable or empty file, + * invalid PEM, or a key that does not match the certificate rejects + * initialization rather than falling back to HTTP. The material is never + * re-read, so replacing a certificate takes a reload. Omitted or null listens + * over plain HTTP. + */ + tls?: TlsConfig /** Response compression for socket-backed HTTP requests. @default 'none' */ compression?: 'none' | 'gzip' /** Gzip DEFLATE level from 0 through 9. @default 1 */ @@ -44,13 +63,27 @@ interface Config { } ``` -`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露)。载体本身不拥有 TLS、认证或 Origin 策略,因此绑定到非回环地址会暴露服务器,除非组合层提供这些控制。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令选择 loopback 并拒绝 `--host 0.0.0.0`;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。 +```ts type-equiv +/** + * TLS material for the HTTPS listener: one certificate chain file and the + * private key file it pairs with. Both hold PEM text and both resolve against + * the process working directory. + */ +interface TlsConfig { + /** Certificate chain file, leaf certificate first, PEM, no passphrase. */ + certFile: string + /** Private key file for the chain's leaf certificate; unencrypted PEM. */ + keyFile: string +} +``` + +没有组合能一次性绑定所有网络接口:未指定地址在加载时即被拒绝。导出的 `isWildcardHost` 与 `isLoopbackHost` 按解析后的地址值分类,IPv6 的 `%zone` 会保留给 `listen`,而分类只读取地址本身。载体不拥有认证或 Origin 策略,并且除非 `tls` 提供证书与密钥,否则以明文 HTTP 提供服务。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令默认选择 loopback 并接受一个具体的 `--host` 地址,非 loopback 绑定以明文 HTTP 提供服务时会在启动时警告;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证,绑定地址加 `--trusted-host` 决定接受哪个浏览器 Host;`--public-url` 只改变公告的 URL。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。 ## 服务 -`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。 +`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。 -处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。 +处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。 @@ -92,6 +125,8 @@ admit(request: ConnectionTrustRequest): PeerAdmission /** * Authenticate one frontend index request, owning a token redirect or 401. + * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, + * which only that listener's protocol decides. * @param request - root or configured-index HTTP request. * @param response - response owned when the result is false. * @returns true only when the frontend may serve index.html. @@ -108,13 +143,37 @@ authenticatedUrl(baseUrl: string): string Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) + + +### `ctx.webApp` — `WebApp` + +The Web GUI this process serves, provided by the `web-runtime` row. + +```ts cordis-catalog +/** + * The URL this GUI advertises, also used for `DSH_WEB_URL` and the startup URL line. + * @returns the configured `publicUrl`, otherwise the listener origin. + */ +getAdvertisedUrl(): string +``` + +Source: [`packages/bundle/web-app/src/index.ts`](../../packages/bundle/web-app/src/index.ts) + ### `ctx.webServer` — `WebServer` -The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber. +The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. ```ts cordis-catalog +/** + * The root URL local clients use to reach the bound listener: the protocol, + * the {@link bindUrlHost} form of the bind address, and the listening port. + * @returns the origin, for example `http://127.0.0.1:3080`. + * @throws before `listen` binds a port, or when the bind address has no URL form. + */ +getOrigin(): string + /** * Register a named route. Duplicate (kind, path) throws — route patterns are * a composition-level contract, so a collision is a misconfiguration. diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 7701312154..bf01196282 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -9,8 +9,8 @@ en: d61a7e78bbc1fb56 zh: 8397869f70cd5b28 /workspaces/the-workspace-entity: - en: 697698bc46adc477 - zh: 73d073933ed68c53 + en: ce5b65fb4d0b0c7b + zh: aa332a17810d583a /workspaces/the-registry-ctx-workspaceregistry: en: 73b54ccf836b7f46 zh: 7ee93ba8bb0a616a diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index e501d7b7bb..8e69b7c597 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -25,9 +25,10 @@ Consumers see only the `Workspace` interface; the implementation stays package-p ```ts type-equiv /** * One workspace: a stable id over an existing directory, a display title, and - * an ordered candidate account of sessions. Membership requires both an id in - * that account and a session header whose canonical cwd equals the workspace - * path. Consumers only see this interface; the implementation stays private. + * an ordered candidate account of sessions. Membership validates header cwd + * against the workspace path, but preserves an existing account when its + * fully qualified cwd is missing. Consumers only see this interface; the + * implementation stays private. */ interface Workspace { /** Stable record id (generated uuid). */ @@ -54,8 +55,11 @@ interface Workspace { * prepended at attach, explicit reordering goes through * `insertSessionBefore`, and activity never reorders. The durable candidate * account is filtered synchronously: missing headers, invalid cwd values, - * and canonical cwd mismatches are never returned. A subsequent workspace - * mutation prunes those filtered candidates durably. + * and canonical cwd mismatches are never returned. A fully qualified cwd + * whose realpath or directory stat fails with ENOENT preserves an existing + * candidate, including a dangling symlink; other filesystem failures do not. + * A subsequent mutation prunes filtered candidates, not missing-directory + * candidates. This does not admit new sessions or recreate directories. */ readonly sessionIds: readonly SessionId[] @@ -113,7 +117,7 @@ interface Workspace { } ``` -Ownership truth is the record's ordered `sessionIds`, never derived from session cwd — but membership requires both: an id on the account and a header whose canonical cwd equals the workspace path, so one session structurally belongs to at most one workspace. Failed writes reject (`insertSessionBefore` account errors as `WorkspaceMoveInvalidError`, storage failures as plain errors); every accepted mutation stamps `updatedAt` and durably prunes candidates that no longer pass the membership check. +Ownership truth is the record's ordered `sessionIds`, never derived from session cwd. Membership validates the recorded header cwd against the workspace path; an existing candidate remains owned when its fully qualified cwd fails resolution or directory stat with `ENOENT`. This preserves missing-directory and dangling-symlink accounts without admitting new sessions. Failed writes reject (`insertSessionBefore` account errors as `WorkspaceMoveInvalidError`, storage failures as plain errors); every accepted mutation stamps `updatedAt` and durably prunes candidates that no longer pass the membership check. ## The registry: `ctx.workspaceRegistry` diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 868712b4c9..4c15fc2ffd 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -25,9 +25,10 @@ type WorkspaceId = Branded<'WorkspaceId'> ```ts type-equiv /** * One workspace: a stable id over an existing directory, a display title, and - * an ordered candidate account of sessions. Membership requires both an id in - * that account and a session header whose canonical cwd equals the workspace - * path. Consumers only see this interface; the implementation stays private. + * an ordered candidate account of sessions. Membership validates header cwd + * against the workspace path, but preserves an existing account when its + * fully qualified cwd is missing. Consumers only see this interface; the + * implementation stays private. */ interface Workspace { /** Stable record id (generated uuid). */ @@ -54,8 +55,11 @@ interface Workspace { * prepended at attach, explicit reordering goes through * `insertSessionBefore`, and activity never reorders. The durable candidate * account is filtered synchronously: missing headers, invalid cwd values, - * and canonical cwd mismatches are never returned. A subsequent workspace - * mutation prunes those filtered candidates durably. + * and canonical cwd mismatches are never returned. A fully qualified cwd + * whose realpath or directory stat fails with ENOENT preserves an existing + * candidate, including a dangling symlink; other filesystem failures do not. + * A subsequent mutation prunes filtered candidates, not missing-directory + * candidates. This does not admit new sessions or recreate directories. */ readonly sessionIds: readonly SessionId[] @@ -113,7 +117,7 @@ interface Workspace { } ``` -所有权的真源是记录中有序的 `sessionIds`,绝不从会话 cwd 派生——但成员资格要求两者同时成立:账本上有其 id,且 header 的规范 cwd 等于工作区路径,因此一个会话在结构上至多属于一个工作区。失败的写入会拒绝(`insertSessionBefore` 的账本错误以 `WorkspaceMoveInvalidError` 拒绝,存储失败以普通错误拒绝);每次被接受的变更都盖上 `updatedAt` 时间戳,并持久修剪不再通过成员资格检查的候选项。 +所有权的真源是记录中有序的 `sessionIds`,绝不从会话 cwd 派生。成员资格将记录的 header cwd 与工作区路径校验;已有候选的完全限定 cwd 在路径解析或目录 stat 时返回 `ENOENT`,仍保留其归属。这会保留目录缺失或符号链接失效时的记账关系,但不会接纳新会话。失败的写入会拒绝(`insertSessionBefore` 的账本错误以 `WorkspaceMoveInvalidError` 拒绝,存储失败以普通错误拒绝);每次被接受的变更都盖上 `updatedAt` 时间戳,并持久修剪不再通过成员资格检查的候选项。 ## 注册表:`ctx.workspaceRegistry` diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index d7936c0fef..4a45861506 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -9,8 +9,8 @@ en: c024da7e8d9cb056 zh: c4cc1a66d2903656 /testing-policy/how-specs-execute: - en: 5d8eb65958da096d - zh: 4a40f1c5fe5b2d14 + en: 022c534e72e23142 + zh: c07a20994224f37c /testing-policy/the-with-key-policy-inference-is-cheap-here: en: d555af53068bf523 zh: ddc4265e5de5b3be diff --git a/docs/testing.md b/docs/testing.md index 9ea6db1501..1ba63f9c35 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -18,7 +18,7 @@ Session fixtures retain headers and payloads but omit body sequence/time envelop ## How specs execute -Forked workers run several spec files at once, the coverage gate splits into concurrent partitions beside the other gates in its job, and the self-hosted runners share one host and one volume. Only the process is isolated: ports, predictable paths, external namespaces, and inherited children are not. Own each acquired resource through teardown. A spec passing only alone has a test defect, not an unstable runner. [dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) owns the allocation, restoration, synchronization, timeout-budget, platform, and teardown rules; its [flake diagnosis workflow](../.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md) classifies an existing probabilistic failure. [Unexpected fork exits](../scripts/coverage-fork-diagnostics.ts) report PID, file, code, and signal. +Forked workers run several spec files at once, the coverage gate splits into [concurrent partitions](../scripts/coverage-partitions.ts) beside the other gates in its job, and the self-hosted runners share one host and one volume. Only the process is isolated: ports, predictable paths, external namespaces, and inherited children are not. Own each acquired resource through teardown. A spec passing only alone has a test defect, not an unstable runner. [dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) owns the allocation, restoration, synchronization, timeout-budget, platform, and teardown rules; its [flake diagnosis workflow](../.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md) classifies an existing probabilistic failure. [Unexpected fork exits](../scripts/coverage-fork-diagnostics.ts) report PID, file, code, and signal. ## The with-key policy: inference is cheap here diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 81c16c60ab..c7545a9686 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -18,7 +18,7 @@ Session fixture 保留 header 与 payload,但省略正文 seq/time envelope; ## spec 如何被执行 -fork 出的 worker 会同时运行多个 spec 文件,coverage gate 会拆成并发的 partition,与同一个 job 中的其它 gate 并排运行,而自托管 runner 共用同一台宿主机和同一个卷。被隔离的只有进程:端口、可预测路径、外部命名空间和继承而来的子进程都不隔离。为每个占用的资源负责到 teardown。只在单独运行时通过的 spec 存在测试缺陷,而非 runner 不稳定。[dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) 负责资源分配、状态恢复、同步、超时预算、平台差异与 teardown 规则;它的 [flake 诊断流程](../.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md)用于归类已经存在的概率性失败。 [fork 意外退出](../scripts/coverage-fork-diagnostics.ts)时会报告 PID、文件、退出码和信号。 +fork 出的 worker 会同时运行多个 spec 文件,coverage gate 会拆成[并发的 partition](../scripts/coverage-partitions.ts),与同一个 job 中的其它 gate 并排运行,而自托管 runner 共用同一台宿主机和同一个卷。被隔离的只有进程:端口、可预测路径、外部命名空间和继承而来的子进程都不隔离。为每个占用的资源负责到 teardown。只在单独运行时通过的 spec 存在测试缺陷,而非 runner 不稳定。[dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) 负责资源分配、状态恢复、同步、超时预算、平台差异与 teardown 规则;它的 [flake 诊断流程](../.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md)用于归类已经存在的概率性失败。 [fork 意外退出](../scripts/coverage-fork-diagnostics.ts)时会报告 PID、文件、退出码和信号。 ## 带密钥策略:推理(inference)在这里很便宜 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index c6d4e1c326..692308776e 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -60,8 +60,8 @@ en: 76fbbf0fe38aef10 zh: 76fbbf0fe38aef10 /tool-schema-catalog/deepseek-ai-dsh-tools/run-code: - en: 2cfa8042a920b191 - zh: bbd0aae394fb3806 + en: d03bca78d96757a7 + zh: 0e7cd389c38e2d9f /tool-schema-catalog/deepseek-ai-dsh-plan-mode: en: b500b530845e26a9 zh: b500b530845e26a9 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 6b075043d7..574af31642 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -525,7 +525,7 @@ ask_user_question keeps the original blocking behavior by default; set `mode: ti ### `run_code` -Execute a TypeScript program against the available tools. Takes two required arguments: `description`, a short summary of what the program does, and `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work). Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run. +Execute a TypeScript program against the available tools. Takes two required arguments: `description`, a short summary of what the program does, and `code`, the program to execute. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. ```json { @@ -537,7 +537,7 @@ Execute a TypeScript program against the available tools. Takes two required arg }, "code": { "type": "string", - "description": "The program: the body of an async TypeScript function." + "description": "The TypeScript program to execute, following this tool's execution instructions." }, "timeoutMs": { "type": "number", diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index c67ea4ecdf..8d36652493 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -529,7 +529,7 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 ### `run_code` -针对可用工具执行 TypeScript 程序。接受两个必填参数:`description`,简要说明该程序做什么;以及 `code`,即异步函数的**函数体**(仅使用可擦除语法;支持顶层 `await` 和 `return`)。请根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。只有打印或返回的内容属于程序输出,请谨慎筛选。含图片的子工具结果会在运行结束后附加。 +针对可用工具执行 TypeScript 程序。接受两个必填参数:`description`,简要说明该程序做什么;以及 `code`,要执行的程序。请根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。请筛选程序输出。含图片的子工具结果会在运行结束后附加。 ```json { @@ -541,7 +541,7 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 }, "code": { "type": "string", - "description": "The program: the body of an async TypeScript function." + "description": "The TypeScript program to execute, following this tool's execution instructions." }, "timeoutMs": { "type": "number", diff --git a/docs/ui-radius.i18n.yaml b/docs/ui-radius.i18n.yaml index a5015aa946..0c8d58fcd9 100644 --- a/docs/ui-radius.i18n.yaml +++ b/docs/ui-radius.i18n.yaml @@ -15,14 +15,14 @@ en: cd8120cba5566e19 zh: 65359436088c8bc5 /dsh-unified-corner-radius-standard/select-the-radius: - en: 06ddd6bf9ef1e48c - zh: 4c42d0c9266a00e8 + en: e736416ec85d6e16 + zh: 0edf60fccc867295 /dsh-unified-corner-radius-standard/reuse-tokens-and-preserve-deliberate-shapes: en: fb6d50f3139ad186 zh: 668cb0c02bd22167 /dsh-unified-corner-radius-standard/nested-geometry-and-hover: - en: 3920921700d8ad1c - zh: fc99da8a5e905a27 + en: 7fe4eb7707e6a9f3 + zh: be7530a05c205e27 /dsh-unified-corner-radius-standard/settings-card-materials: en: 85e7e63183df06d1 zh: 1da2b44083d7ebe2 diff --git a/docs/ui-radius.md b/docs/ui-radius.md index d6d28568ca..303e7c9382 100644 --- a/docs/ui-radius.md +++ b/docs/ui-radius.md @@ -38,9 +38,9 @@ H means designed outer height in CSS pixels, including borders. R means CSS `bor Prefer an existing shared size variant when a proposed control falls between these bands. A taller card remains a card: do not promote it to R28 merely because it exceeds a button's height. A multiline cell intentionally designed for multiple lines uses R16; an ordinary button whose label wraps retains its button variant. -Concrete defaults: `Button` uses H28/R8 for `sm` and H36/R12 for `md`, including outline variants. Standard H32 inputs and H36 selectors use R12. The Workspace files and New terminal guide entries both use R20, including title-only and described variants. Reserve R28 for their enclosing panel or dialog, not those entries. +Concrete defaults: `Button` uses H28/R8 for `sm` and H36/R12 for `md`, including outline variants. Standard H32 inputs and H36 selectors use R12. The Workspace files and New terminal guide entries both use R20, including title-only and described variants. Reserve R28 for their enclosing panel or dialog, not those entries. Composer statistics and the context-occupancy trigger use compact-control R8 with the theme curve, including hover, expanded, and focus states. -Code blocks, diffs, and file previews share R16: changed-file entry cards, delivered-file cards, generic file attachment cards, and hover preview panels. The 40px file-icon tiles inside changed-file and delivery cards use R10 on all four corners as a component-specific exception. Their hover fills keep the same contour. +Code blocks, diffs, and preview cards share R16: changed-file entry cards, delivered-file cards, submitted-plan cards, created-task cards, generic file attachment cards before and after sending, and hover preview panels. The 40px icon tiles inside changed-file, delivery, submitted-plan, and created-task cards use R10 on all four corners as a component-specific exception and share the same neutral tile fill and border in both themes. Their hover fills keep the same contour. ## Reuse tokens and preserve deliberate shapes @@ -64,11 +64,13 @@ Distinguish these three arrangements before changing a child radius: | Arrangement | Rule | Example | |---|---|---| -| Concentric inset | Inner R = max(0, outer R − inset); use the same curve | Outer R16, inset 4, inner R12 | +| Circular concentric inset | Inner R = max(0, outer R − inset); both arcs share a center and use `round` | Outer R16, inset 4, inner R12 | | Fill flush against an enclosing edge | The enclosure owns the visible corner; clip the fill or inherit the matching corner | A split-button card with two independent hover regions | | Separate card or form group inside another surface | Choose the child's semantic tier; do not subtract all parent padding mechanically | R16 editor within an R20 settings card | -Standard menus use outer R16, 4px padding, and R12 items. Compact menus use outer R12, 4px padding, and R8 items. Shared `SegmentedControl` uses outer R12, 4px inset, and R8 segments/indicator; `SegmentedTabs` uses outer R16, 4px inset, and R12 segments/indicator. Keep indicator offsets, width calculations, submenu alignment, and pointer bridges consistent when changing padding. +Standard menus use outer R16, 4px padding, and R12 items, including command selection, task menus, and the exposed highlights in subagent and background-job menus. Tree disclosure gutters retain their indentation; controls inside a filled job row keep their own compact geometry. Compact menus use outer R12, 4px padding, and R8 items. Shared `SegmentedControl` uses outer R12, 4px inset, and R8 segments/indicator; `SegmentedTabs` uses outer R16, 4px inset, and R12 segments/indicator. Keep indicator offsets, width calculations, submenu alignment, and pointer bridges consistent when changing padding. + +The radius subtraction is also the nominal sizing rule for nested superellipses. Equal exponents do not make them exact offset curves, so their diagonal gap may differ from the side inset. Keep the shared superellipse menu and segmented-control treatments; use circular arcs when strict equal radial spacing is required. A single clickable surface paints hover on itself. A flush pseudo-element or overlay uses the same radius and curve as the visible enclosure. Avoid a separately rounded fill that leaves a crescent between the background and the border. diff --git a/docs/ui-radius.zh.md b/docs/ui-radius.zh.md index 657684ebc7..80edb753c6 100644 --- a/docs/ui-radius.zh.md +++ b/docs/ui-radius.zh.md @@ -38,9 +38,9 @@ H 表示以 CSS 像素计、包含边框的设计外部高度。R 表示浏览 拟定控件尺寸落在档位之间时,优先复用已有的公共尺寸变体。高卡片仍是卡片,不因高度超过按钮就升到 R28。专门设计为多行的 cell 使用 R16;普通按钮的文字发生换行时,仍保留其按钮变体。 -具体默认值:`Button` 的 `sm` 使用 H28/R8,`md` 使用 H36/R12,包含描边变体。标准 H32 输入框和 H36 选择器使用 R12。“工作区文件”和“新建终端”引导入口统一使用 R20,包含仅标题与带描述的变体。R28 留给包围它们的面板或对话框,不用于这些入口。 +具体默认值:`Button` 的 `sm` 使用 H28/R8,`md` 使用 H36/R12,包含描边变体。标准 H32 输入框和 H36 选择器使用 R12。“工作区文件”和“新建终端”引导入口统一使用 R20,包含仅标题与带描述的变体。R28 留给包围它们的面板或对话框,不用于这些入口。输入框统计控件与上下文占用率入口使用紧凑控件的 R8 和主题曲线,包含 hover、展开与焦点状态。 -代码块、diff 与文件预览共用 R16:包括改动文件入口卡片、交付文件卡片、通用附件文件卡片和悬停预览浮层。改动文件与交付文件卡片内部的 40px 文件图标底框作为组件特例,四角统一使用 R10。各自的 hover 保持同一轮廓。 +代码块、diff 与预览卡片共用 R16:包括改动文件入口卡片、交付文件卡片、已提交计划卡片、已创建任务卡片、发送前后的通用附件文件卡片和悬停预览浮层。改动文件、交付文件、已提交计划与已创建任务卡片内部的 40px 图标底框作为组件特例,四角统一使用 R10,并在明暗两种主题下分别共用相同的中性底色与边框。各自的 hover 保持同一轮廓。 ## 复用 token,保留有意设计的形状 @@ -64,11 +64,13 @@ H 表示以 CSS 像素计、包含边框的设计外部高度。R 表示浏览 | 关系 | 规则 | 示例 | |---|---|---| -| 同心内缩 | 内层 R = max(0, 外层 R − 内缩距离);使用相同曲线 | 外层 R16,内缩 4,内层 R12 | +| 圆弧同心内缩 | 内层 R = max(0, 外层 R − 内缩距离);两段圆弧同心且使用 `round` | 外层 R16,内缩 4,内层 R12 | | 底色紧贴容器边缘 | 由外框决定可见圆角;裁切底色或继承相应圆角 | 两个独立 hover 区域组成的分体按钮卡片 | | 容器内独立的卡片或表单组 | 按子元素用途选择档位;不机械减去全部父级内边距 | R20 设置卡片内的 R16 编辑器 | -标准菜单使用外层 R16、4px 内边距、R12 菜单项。紧凑菜单使用外层 R12、4px 内边距、R8 菜单项。公共 `SegmentedControl` 使用外层 R12、4px 内缩、R8 分段和指示器;`SegmentedTabs` 使用外层 R16、4px 内缩、R12 分段和指示器。修改内边距时,同步保持指示器偏移、宽度计算、子菜单对齐与指针桥接区域一致。 +标准菜单使用外层 R16、4px 内边距、R12 菜单项,包含命令选择、任务菜单,以及子会话和后台任务菜单的外露高亮区域。树形展开操作保留缩进;带底色任务行内的控件保留各自的紧凑几何。紧凑菜单使用外层 R12、4px 内边距、R8 菜单项。公共 `SegmentedControl` 使用外层 R12、4px 内缩、R8 分段和指示器;`SegmentedTabs` 使用外层 R16、4px 内缩、R12 分段和指示器。修改内边距时,同步保持指示器偏移、宽度计算、子菜单对齐与指针桥接区域一致。 + +半径相减也用于嵌套超级椭圆的名义尺寸关系。相同指数不意味着两条曲线严格等距,因此对角线间隙可能不同于侧边内缩。菜单与分段控件保留公共的超级椭圆样式;要求径向严格等距时使用圆弧。 单个可点击表面在自身绘制 hover。贴边的伪元素或覆盖层使用与可见外框相同的圆角和曲线。避免单独给底色设置圆角,导致背景与边框之间出现月牙状空隙。 diff --git a/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.i18n.yaml b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.i18n.yaml new file mode 100644 index 0000000000..5d121913f3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.md +/: + en: 985656162a55b14a + zh: c32bda15938879e5 +/numpy-and-pandas-upstream-tests-require-a-separate-environment: + en: ec0ae29022f8ce8c + zh: f5e49682b842544d +/numpy-and-pandas-upstream-tests-require-a-separate-environment/change: + en: 41410ee875be7975 + zh: b052e7b3c8fb096b +/numpy-and-pandas-upstream-tests-require-a-separate-environment/migration: + en: 00c4b10f8df805e7 + zh: 06ef0fbda56c9acf diff --git a/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.md b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.md new file mode 100644 index 0000000000..0c3978d20b --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Bundled NumPy and pandas omit their upstream test suites." +--- + +# NumPy and pandas upstream tests require a separate environment + +English | [中文](guide.zh.md) + +## Change + +Desktop and Python SDK runtime distributions previously included NumPy and pandas test directories. Bundled distributions now omit those directories, so scripts importing their test modules or running their complete upstream suites need a separate installation. Data processing, Office authoring, `numpy.testing`, `pandas.testing`, and `pandas._testing` remain available. + +## Migration + +1. If your scripts import `numpy.*.tests` or `pandas.tests`, install the original NumPy and pandas distributions and their upstream testing dependencies in a separate Python environment. Keep that environment outside the managed Harness runtime directory, which upgrades replace. +2. Run the affected scripts or upstream suites with that environment's interpreter. Scripts that use only library functionality or retained testing helpers require no changes. +3. Confirm that the previously failing test-module imports and affected scripts succeed with the separate interpreter. The bundled versions are recorded in the [runtime lock](../../../../scripts/primary-runtime/lock.json). diff --git a/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.zh.md b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.zh.md new file mode 100644 index 0000000000..9e36c71dc6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "内置 NumPy 和 pandas 不包含其上游测试套件。" +--- + +# NumPy 和 pandas 上游测试需要独立环境 + +[English](guide.md) | 中文 + +## 变更 + +Desktop 和 Python SDK 运行时分发包此前包含 NumPy 和 pandas 测试目录。内置分发包现在裁剪这些目录,因此导入其测试模块或运行其完整上游测试套件的脚本需要独立安装环境。数据处理、Office 创作、`numpy.testing`、`pandas.testing` 和 `pandas._testing` 仍然可用。 + +## 迁移 + +1. 如果脚本导入 `numpy.*.tests` 或 `pandas.tests`,请在独立 Python 环境中安装原始 NumPy 和 pandas 分发包及其上游测试依赖。请将该环境放在 Harness 托管运行时目录之外,升级会替换托管目录。 +2. 使用该环境的解释器运行受影响的脚本或上游测试套件。仅使用库功能或保留的 testing 辅助 API 的脚本无需修改。 +3. 确认先前失败的测试模块导入及相关脚本均可在独立解释器中成功运行。随包版本记录在[运行时锁文件](../../../../scripts/primary-runtime/lock.json)中。 diff --git a/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.i18n.yaml b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.i18n.yaml new file mode 100644 index 0000000000..5fae57fcd3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.md +/: + en: a4f973e3aed3cbb1 + zh: e33e5eb33838f82e +/custom-python-runtime-aliases-must-target-a-sibling-interpreter: + en: c38fad48cd870c4c + zh: 49fff1636ca8c8fd +/custom-python-runtime-aliases-must-target-a-sibling-interpreter/change: + en: 55f21ac0b9190e37 + zh: 2d1c7aaa89ddfe19 +/custom-python-runtime-aliases-must-target-a-sibling-interpreter/migration: + en: 2839866a08be236d + zh: 11a01d35a7f396ce diff --git a/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.md b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.md new file mode 100644 index 0000000000..1a3545ee9b --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Custom primary-runtime payloads require relocatable Python interpreter aliases." +--- + +# Custom Python runtime aliases must target a sibling interpreter + +English | [中文](guide.zh.md) + +## Change + +On macOS and Linux, `DSH_PRIMARY_RUNTIME` previously accepted interpreter links that resolved outside their payload or through other links. Payload validation now requires each linked `dependencies/python/bin/python` or `python3` to point directly to the regular sibling interpreter named for `runtime.json`'s Python major and minor version, such as `python3.12`. Regular command files remain supported, including those distributed in Python wheels. Windows is unchanged. + +## Migration + +1. For a custom payload, keep the versioned interpreter in `dependencies/python/bin/` and make `python` and `python3` relative links to its filename. The [runtime preparation instructions](../../../../packages/skill/tool-workspace-dependencies/README.md) list the required target and output arguments. +2. Preserve relative links when copying prepared resources. With Node `fs.cp`, use `verbatimSymlinks: true`; wheel builders must materialize the command files because pip does not restore links. The native build smoke requires the prepared resource tree to retain both aliases. +3. Confirm that `readlink` reports only the sibling filename for both aliases, then move the payload away from its build directory, point `DSH_PRIMARY_RUNTIME` at its new directory, and verify that `load_workspace_dependencies` and the returned Python command succeed. diff --git a/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.zh.md b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.zh.md new file mode 100644 index 0000000000..e623f41857 --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "自定义 primary-runtime payload 的 Python 解释器别名必须支持目录迁移。" +--- + +# 自定义 Python 运行时别名必须指向同目录解释器 + +[English](guide.md) | 中文 + +## 变更 + +在 macOS 和 Linux 上,`DSH_PRIMARY_RUNTIME` 之前接受指向 payload 外部或经过其他链接的解释器链接。现在,payload 校验要求 `dependencies/python/bin/python` 或 `python3` 的链接直接指向同目录的普通解释器文件,文件名由 `runtime.json` 中 Python 的主版本号和次版本号决定,例如 `python3.12`。普通命令文件仍受支持,包括 Python wheel 中分发的文件。Windows 不变。 + +## 迁移 + +1. 对于自定义 payload,将版本化解释器保留在 `dependencies/python/bin/` 中,并让 `python` 和 `python3` 以该文件名为目标建立相对链接。[运行时准备说明](../../../../packages/skill/tool-workspace-dependencies/README.zh.md)列出了必需的目标与输出参数。 +2. 复制准备好的资源时保留相对链接。使用 Node `fs.cp` 时设置 `verbatimSymlinks: true`;wheel 构建器必须将命令展开为普通文件,因为 pip 不会还原链接。本机构建冒烟检查要求准备好的资源目录保留两个别名。 +3. 确认 `readlink` 对两个别名都只返回同目录解释器的文件名,再将 payload 移离构建目录,将 `DSH_PRIMARY_RUNTIME` 指向新目录,验证 `load_workspace_dependencies` 和返回的 Python 命令均可成功执行。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.i18n.yaml new file mode 100644 index 0000000000..8e8646abe0 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.md +/: + en: 059ab5482d52d8b8 + zh: 006b65489bc4ec70 +/deepseek-api-key-model-visibility: + en: 7b14dc7133967bfd + zh: 4f338ae1e8151feb +/deepseek-api-key-model-visibility/change: + en: ab0b141fbb3167ab + zh: 918f4202223abe44 +/deepseek-api-key-model-visibility/migration: + en: 2fda36f7e543f1d0 + zh: e8522f256de5adaa diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.md new file mode 100644 index 0000000000..a5fa635ccd --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "DeepSeek API-key model discovery requires a configured credential." +--- + +# DeepSeek API-key model visibility + +English | [中文](guide.zh.md) + +## Change + +The `deepseek-official` route previously listed its configured models without an API key. It now returns an empty catalog when its credential is missing, so Desktop and Web hide the DeepSeek group. Model discovery reports malformed credentials and credential lookup failures. A saved or default selection absent from the catalog displays “Select model” without changing the stored selection. The independent DeepSeek account route still uses account login. + +## Migration + +1. To use the API-key route, configure its key in Settings → Models or supply the reference named by `apiKeyEnv` (default `DEEPSEEK_API_KEY`) through the launch environment. +2. Reopen the model selector and confirm the DeepSeek group appears. Custom discovery consumers must handle an empty catalog before credentials are configured; listing models does not verify that the remote API accepts the key. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.zh.md new file mode 100644 index 0000000000..46ff3452ff --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "DeepSeek API Key 模型发现要求已配置凭据。" +--- + +# DeepSeek API Key 模型可见性 + +[English](guide.md) | 中文 + +## 变更 + +`deepseek-official` 路由此前在没有 API Key 时也会列出配置的模型。现在凭据缺失时返回空目录,因此桌面端和 Web 隐藏 DeepSeek 分组。模型发现会报告凭据格式错误和凭据读取失败。已保存或默认的选择不在目录中时,界面显示“请选择模型”,保存的选择保持不变。独立的 DeepSeek 账号路由仍使用账号登录。 + +## 迁移 + +1. 要使用 API Key 路由,请在设置 → 模型中配置密钥,或通过启动环境提供 `apiKeyEnv` 指定的凭据引用(默认为 `DEEPSEEK_API_KEY`)。 +2. 重新打开模型选择器,确认 DeepSeek 分组出现。自定义模型发现消费者必须处理配置凭据前的空目录;模型列出不代表远端 API 已接受密钥。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.i18n.yaml new file mode 100644 index 0000000000..d7f903cbe4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md +/: + en: d6baeff3354ba733 + zh: 54e736a9718228f8 +/download-optional-python-runtime-resources-explicitly: + en: b9a46f870d69cf79 + zh: 1ed1da9167fad3b1 +/download-optional-python-runtime-resources-explicitly/change: + en: 92d45d6f2a499e9b + zh: d4560da85fc5cfbf +/download-optional-python-runtime-resources-explicitly/migration: + en: 0f0b15be52301aed + zh: 646d7b47069714d4 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md new file mode 100644 index 0000000000..21c5a025e5 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Python runtime wheels require explicit downloads for Office conversion and bundled authoring environments." +--- + +# Download optional Python runtime resources explicitly + +English | [中文](guide.zh.md) + +## Change + +Python runtime wheels omit the Office sidecar and authoring interpreters and libraries. SDK startup does not download them. Office operations require an explicit download; the authoring environment and conversion engine can be selected independently. The SDK still packages the normal `dsh` CLI and requires no system Node.js. + +## Migration + +1. Before processing Office files, call `deepseek_harness_runtime.download_office()`. Before using the bundled authoring environment, call `deepseek_harness_runtime.download_primary_runtime()`. Both reuse completed caches and return absolute paths. +2. For offline deployment, prepare the cache with the matching runtime release on the target platform and copy it to that platform's deployment. Set `DSH_RESOURCE_CACHE` to its absolute root before downloads and SDK startup. See the [runtime reference](../../../../python/sdk-runtime/README.md). +3. Start the SDK and confirm the required resources are available. `deepseek_harness_runtime.resolve_office_launch_args()` checks the Office CLI paths without downloading. Existing `DSH_PRIMARY_RUNTIME` paths remain supported; an empty value disables the authoring query. Profile patches can independently disable `skill-office`. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.zh.md new file mode 100644 index 0000000000..9c804c6d34 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Python 运行时 wheel 的 Office 转换与内置创作环境需要显式下载。" +--- + +# 显式下载可选 Python 运行时资源 + +[English](guide.md) | 中文 + +## 变更 + +Python 运行时 wheel 不包含 Office sidecar、创作解释器和库。SDK 启动不会下载这些资源。Office 操作需要显式下载;创作环境和转换引擎可以独立选择。SDK 仍打包常规 `dsh` CLI,无需系统 Node.js。 + +## 迁移 + +1. 处理 Office 文件前调用 `deepseek_harness_runtime.download_office()`。使用内置创作环境前调用 `deepseek_harness_runtime.download_primary_runtime()`。两者复用完整缓存并返回绝对路径。 +2. 离线部署时,在目标平台使用匹配的运行时发布版本准备缓存,并将其复制到该平台的部署环境。下载和启动 SDK 前,将 `DSH_RESOURCE_CACHE` 设为缓存的绝对根目录。详见[运行时参考](../../../../python/sdk-runtime/README.zh.md)。 +3. 启动 SDK 并确认所需资源可用。`deepseek_harness_runtime.resolve_office_launch_args()` 检查 Office CLI 路径,不执行下载。既有 `DSH_PRIMARY_RUNTIME` 路径仍受支持;空值禁用创作环境查询。Profile patch 可独立禁用 `skill-office`。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.i18n.yaml new file mode 100644 index 0000000000..f972f05aa0 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.md +/: + en: 08f294cc358eaffb + zh: 46ef86d9f933ea58 +/migrate-session-title-providers: + en: c9e33d3bdc0595c6 + zh: 55a049680ff8ae9b +/migrate-session-title-providers/change: + en: 3a8d72fb2c793d51 + zh: 9acc89004dd31096 +/migrate-session-title-providers/migration: + en: ea9efdc5234c0541 + zh: 845d80a7c6c05672 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.md new file mode 100644 index 0000000000..bf0eb87a91 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Session-title providers register directly, own their strategy settings, and share only prepared execution." +--- + +# Migrate session-title providers + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-session-title-llm` no longer exports `registerSessionTitleLlmProvider`, `generateSessionTitleWithLlm`, or `SessionTitleLlmMessageSelector`. `SessionTitleLlmConfig` now holds only execution controls (`maxInputBytes`, `maxOutputTokens`, `timeoutMs`, and the optional `provider`/`model` pair); `targetWords` and `targetCjkCharacters` moved into each provider's own `Config`. Each provider owns its system instruction, message framing, title parsing, reasoning selection, and output interpretation, registers directly with `ctx.sessionTitle.register()`, and calls `executeSessionTitleLlm(ctx, config, request, providerId, { system, input, messageSeqs, selectReasoningEffort })`. That function returns the assembled content blocks, the terminal finish, and the used model route. `SessionTitleProviderRequest` gains an optional `currentTitle` snapshot. + +## Migration + +1. Replace a `registerSessionTitleLlmProvider(...)` call with a direct `ctx.sessionTitle.register({ id, automatic, generate })`; keep the same `id` and cadence. +2. In `generate(request)`, build your own system prompt and user input, pass a `selectReasoningEffort` function, then call `executeSessionTitleLlm` with `{ system, input, messageSeqs, selectReasoningEffort }`. +3. Reject a `tool-calls` or `max-tokens` terminal finish and tool-call blocks; derive the title from the text blocks. +4. Read `request.currentTitle` when the strategy should preserve an accepted provider title; it is absent before any title. +5. Generate a title and confirm the saved result plus the exact `session/title-llm-request` record. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.zh.md new file mode 100644 index 0000000000..9bf62d63fe --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "会话标题提供方直接注册、拥有自己的策略设置,并只共享准备好的执行。" +--- + +# 迁移会话标题提供方 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-title-llm` 不再导出 `registerSessionTitleLlmProvider`、`generateSessionTitleWithLlm` 或 `SessionTitleLlmMessageSelector`。`SessionTitleLlmConfig` 现在只包含执行控制(`maxInputBytes`、`maxOutputTokens`、`timeoutMs` 以及可选的 `provider`/`model` 对);`targetWords` 与 `targetCjkCharacters` 已移入各提供方自己的 `Config`。各提供方拥有自己的系统指令、消息封装、标题解析、推理选择与输出解释,直接通过 `ctx.sessionTitle.register()` 注册,并调用 `executeSessionTitleLlm(ctx, config, request, providerId, { system, input, messageSeqs, selectReasoningEffort })`。该函数返回组装后的内容块、终止结束原因与所用模型路由。`SessionTitleProviderRequest` 新增可选的 `currentTitle` 快照。 + +## 迁移 + +1. 把 `registerSessionTitleLlmProvider(...)` 调用替换为直接 `ctx.sessionTitle.register({ id, automatic, generate })`;保持相同的 `id` 与节奏。 +2. 在 `generate(request)` 中构建自己的系统提示词与用户输入,传入 `selectReasoningEffort` 函数,然后用 `{ system, input, messageSeqs, selectReasoningEffort }` 调用 `executeSessionTitleLlm`。 +3. 拒绝 `tool-calls` 或 `max-tokens` 终止结束原因与工具调用块;从文本块派生标题。 +4. 当策略需要保留已接纳的提供方标题时读取 `request.currentTitle`;任何标题被接纳前该值为缺省。 +5. 生成一次标题,确认保存的结果以及确切的 `session/title-llm-request` 记录。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.i18n.yaml new file mode 100644 index 0000000000..4f0fc7d434 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.md +/: + en: ece09d74b8b3da0e + zh: 4762937e93aa8bf0 +/ssh-helper-and-ptc-launch-configuration: + en: e757aad6a0030eb3 + zh: 2f87638444248a40 +/ssh-helper-and-ptc-launch-configuration/change: + en: 7e78555e386835b1 + zh: 5a0d9de8e1312682 +/ssh-helper-and-ptc-launch-configuration/migration: + en: 33f5268892e099c9 + zh: c97f51289a61d323 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.md new file mode 100644 index 0000000000..5f32a8363d --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "SSH and Node PTC configurations use explicit launch modes, and SSH clients require a matching protocol-2 helper." +--- + +# SSH helper and PTC launch configuration + +English | [中文](guide.zh.md) + +## Change + +Custom SSH compositions must update their `cordis.yml` and installed helper together. `dsh-ssh` replaces top-level `node`, `bootstrapPath` and `bootstrapHash` with `launch`, and requires SSH protocol 2. `dsh-ptc-runtime-node` replaces `nodeExecutable` and `bootstrapPath` with `launch`. The connection's `nodeExecutable` and `bootstrapPath` getters are replaced by `ptcLaunch`. + +## Migration + +1. For a script helper, move `node`, `bootstrapPath` and `bootstrapHash` under `launch: { kind: node-script }`. Keep `helper`, `helperHash`, `host` and `workspace` at their existing locations. Install the matching helper script and update `helperHash`; update the bootstrap and its hash together when configured. +2. For a packaged helper, extract the complete archive and set `launch: { kind: executable }`, `helper` to the executable's absolute remote path, and `helperHash` to its digest from `manifest.json`. +3. Configure the remote PTC provider with `launch: ctx.ssh.ptcLaunch` in the composition's existing JavaScript configuration. For an explicitly configured local Node worker, use `launch: { kind: 'node-script', executable: previousNodeExecutable, bootstrapPath: previousBootstrapPath }`. Omitted local launch configuration still selects the current process's runtime. +4. Start the configured profile and exercise a remote file read, a process command and a PTC call. A protocol or digest mismatch requires updating the installed helper and its configuration together. See the [SSH deployment requirements](../../../../packages/ssh/ssh/README.md#use-this-package). diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.zh.md new file mode 100644 index 0000000000..eb6dca6dfa --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "SSH 和 Node PTC 配置改用显式启动模式,SSH 客户端要求使用匹配的协议 2 helper。" +--- + +# SSH helper 与 PTC 启动配置 + +[English](guide.md) | 中文 + +## 变更 + +自定义 SSH 组合必须一起更新 `cordis.yml` 和已安装的 helper。`dsh-ssh` 将顶层 `node`、`bootstrapPath` 和 `bootstrapHash` 替换为 `launch`,并要求 SSH 协议 2。`dsh-ptc-runtime-node` 将 `nodeExecutable` 和 `bootstrapPath` 替换为 `launch`。连接的 `nodeExecutable` 和 `bootstrapPath` getter 替换为 `ptcLaunch`。 + +## 迁移 + +1. 使用脚本 helper 时,将 `node`、`bootstrapPath` 和 `bootstrapHash` 移入 `launch: { kind: node-script }`。`helper`、`helperHash`、`host` 和 `workspace` 保持原位置。安装匹配的 helper 脚本并更新 `helperHash`;配置了 bootstrap 时,一起更新该文件及其摘要。 +2. 使用打包后的 helper 时,解压完整发行包,设置 `launch: { kind: executable }`,将 `helper` 设为可执行文件的远端绝对路径,将 `helperHash` 设为 `manifest.json` 中该文件的摘要。 +3. 在组合现有的 JavaScript 配置中,通过 `launch: ctx.ssh.ptcLaunch` 配置远端 PTC 提供方。显式配置本地 Node worker 时,使用 `launch: { kind: 'node-script', executable: previousNodeExecutable, bootstrapPath: previousBootstrapPath }`。省略本地启动配置时,仍选择当前进程的运行时。 +4. 启动配置好的 profile,执行一次远端文件读取、进程命令和 PTC 调用。出现协议或摘要不匹配时,需要一起更新已安装的 helper 及其配置。参见 [SSH 部署要求](../../../../packages/ssh/ssh/README.zh.md#use-this-package)。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.i18n.yaml new file mode 100644 index 0000000000..6e50b44007 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.md +/: + en: af5832d4e9eece99 + zh: 8f97dad6921ed8a0 +/web-listener-and-trust-configuration-moves-to-webstartup: + en: de3788e700062659 + zh: 9def0e8f38896304 +/web-listener-and-trust-configuration-moves-to-webstartup/change: + en: 19f901fb2974aab5 + zh: b4ef886f85b61637 +/web-listener-and-trust-configuration-moves-to-webstartup/migration: + en: 30917eb37ead3e10 + zh: fd1b26eada570dd3 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.md new file mode 100644 index 0000000000..6f18f4d7a1 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.md @@ -0,0 +1,36 @@ +--- +kind: upgrade-guide +description: "The Web profile replaces the `webRuntime` service and the `web-runtime` row's `trustedHosts` config with `webStartup`, and rejects a wildcard listener host." +--- + +# Web listener and trust configuration moves to `webStartup` + +English | [中文](guide.zh.md) + +## Change + +The Web profile no longer provides the `webRuntime` service, and the `web-runtime` row no longer declares a `trustedHosts` config value. A profile, overlay, or `--patch` file that injects `webRuntime` now waits for a service that never mounts, and an expression reading `ctx.webRuntime.trustedHosts` fails to evaluate, so the required Connection cannot start. + +The `web-startup` row provides one `webStartup` service carrying the invocation's `--trusted-host` authorities, and the `connection` row reads them from `ctx.webStartup.trustedHosts`. The listener host must also be one concrete IPv4 or IPv6 address of a local interface: `host: 0.0.0.0`, `--host ::`, and every other unspecified address are rejected at load, because binding every interface exposes the port to the network. + +## Migration + +1. Replace `inject: [webRuntime]` with `inject: [webStartup]` on every row that needs invocation trust. +2. Replace `ctx.webRuntime.trustedHosts` expressions with `ctx.webStartup.trustedHosts`, and drop `ctx.webRuntime.lanAddresses`, which no longer exists. +3. Rewrite the `connection` overlay for `webStartup`, and move any `trustedHosts` value that the `web-runtime` row carried into its expression. A patch replaces the matched row's whole `config`, so restate that config completely, and write an array-valued `!!js` expression as a quoted scalar: + + ```yaml + # before + - id: connection + inject: [webRuntime] + config: + trustedHosts: !!js "['app.internal', ...ctx.webRuntime.trustedHosts]" + # after + - id: connection + inject: [webStartup] + config: + trustedHosts: !!js "['app.internal', ...ctx.webStartup.trustedHosts]" + ``` + +4. Replace a wildcard `host` with one concrete address of a local interface. The bind IP itself is accepted by the Host fence with no `--trusted-host` entry; a proxy or DNS authority still needs one. +5. Boot with the migrated overlay: `dsh --profile web --patch ./extra.yml --no-open` must print the `dsh web:` URL line and serve. A row still waiting on `webRuntime` leaves the required Connection pending and reports an activation failure, and a rejected `host` fails at load. `dsh --profile web --patch ./extra.yml --dump-config` prints the composed patch before boot. [The Web bundle README](../../../../packages/bundle/web-app/README.md) owns the composed rows. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.zh.md new file mode 100644 index 0000000000..5e9f3d211f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.zh.md @@ -0,0 +1,36 @@ +--- +kind: upgrade-guide +description: "Web profile 用 `webStartup` 取代 `webRuntime` 服务与 `web-runtime` 行的 `trustedHosts` 配置,并拒绝通配监听地址。" +--- + +# Web 监听与信任配置迁移到 `webStartup` + +[English](guide.md) | 中文 + +## 变更 + +Web profile 不再提供 `webRuntime` 服务,`web-runtime` 行也不再声明 `trustedHosts` 配置值。仍注入 `webRuntime` 的 profile、overlay 或 `--patch` 文件会等待一个永不挂载的服务,读取 `ctx.webRuntime.trustedHosts` 的表达式则会求值失败,导致必需的 Connection 无法启动。 + +`web-startup` 行提供唯一的 `webStartup` 服务,携带本次调用的 `--trusted-host` authority;`connection` 行通过 `ctx.webStartup.trustedHosts` 读取它们。监听 host 现在也必须是本机网卡的一个具体 IPv4 或 IPv6 地址:`host: 0.0.0.0`、`--host ::` 及其他未指定地址都会在加载时被拒绝,因为绑定所有接口会把端口暴露到网络。 + +## 迁移 + +1. 将所有需要调用信任的行的 `inject: [webRuntime]` 换成 `inject: [webStartup]`。 +2. 将 `ctx.webRuntime.trustedHosts` 表达式换成 `ctx.webStartup.trustedHosts`,并删除已不存在的 `ctx.webRuntime.lanAddresses`。 +3. 按 `webStartup` 改写 `connection` overlay,并把原先由 `web-runtime` 行携带的 `trustedHosts` 值并入其表达式。patch 会整体替换所匹配行的 `config`,因此必须完整重述该配置;数组值的 `!!js` 表达式要写成带引号的标量: + + ```yaml + # before + - id: connection + inject: [webRuntime] + config: + trustedHosts: !!js "['app.internal', ...ctx.webRuntime.trustedHosts]" + # after + - id: connection + inject: [webStartup] + config: + trustedHosts: !!js "['app.internal', ...ctx.webStartup.trustedHosts]" + ``` + +4. 将通配 `host` 换成本机网卡的一个具体地址。所绑定的 IP 会被 Host 栅栏直接接受,无需 `--trusted-host`;代理或 DNS authority 仍需显式配置。 +5. 使用迁移后的 overlay 启动:`dsh --profile web --patch ./extra.yml --no-open` 必须打印 `dsh web:` URL 行并正常服务。仍在等待 `webRuntime` 的行会让必需的 Connection 保持 pending 并报告激活失败;被拒绝的 `host` 会在加载时失败。`dsh --profile web --patch ./extra.yml --dump-config` 可在启动前打印组合后的 patch。[Web 组合包 README](../../../../packages/bundle/web-app/README.zh.md)说明这些行。 diff --git a/docs/user/guide/public-deployments.i18n.yaml b/docs/user/guide/public-deployments.i18n.yaml index 7bd0d94673..757429dcc0 100644 --- a/docs/user/guide/public-deployments.i18n.yaml +++ b/docs/user/guide/public-deployments.i18n.yaml @@ -3,17 +3,17 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/public-deployments.md /publish-the-web-ui-behind-a-reverse-proxy: - en: 15b92e92d2526f37 - zh: b58079cf6eee78cc + en: 867565aa675f3fb3 + zh: c60606b5e5bdb778 /publish-the-web-ui-behind-a-reverse-proxy/what-public-url-advertises: - en: 576292b93ccf299a - zh: ded5adc55396869c + en: 924b28b211478f01 + zh: b098d5a53a29c883 /publish-the-web-ui-behind-a-reverse-proxy/what-the-proxy-must-do: - en: 8dd9d910a93fe54d - zh: 1e9e21d9949c8ab2 + en: 2f93051763125365 + zh: 1dd699cb84ecf034 /publish-the-web-ui-behind-a-reverse-proxy/trust-the-authority-browsers-use: - en: 907b5f90c9860733 - zh: dcbffd3f00d6c64f + en: 08e5a6cc45918876 + zh: d59178e10e915748 /publish-the-web-ui-behind-a-reverse-proxy/secure-the-external-leg: - en: a833880da5c8bbf4 - zh: 6f9bd565b7266241 + en: 2afac1f10e2d2e88 + zh: f9b4b03a5aad27e1 diff --git a/docs/user/guide/public-deployments.md b/docs/user/guide/public-deployments.md index 22bf5f8b0c..70e107fe6d 100644 --- a/docs/user/guide/public-deployments.md +++ b/docs/user/guide/public-deployments.md @@ -2,7 +2,7 @@ English | [中文](public-deployments.zh.md) -`dsh --profile web` serves the GUI over plain HTTP on a loopback port, so a browser on another machine cannot reach it, and the process knows nothing about the address you do use. A reverse proxy in front of it owns that external leg — the public host name, TLS, and the path prefix it strips before forwarding to the listener — and `--public-url` tells DSH which address browsers use: +`dsh --profile web` serves the GUI on a loopback port, so a browser on another machine cannot reach it, and the process knows nothing about the address you do use. A reverse proxy in front of it owns that external leg — the public host name, TLS, and the path prefix it strips before forwarding to the listener — and `--public-url` tells DSH which address browsers use: ```sh dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example @@ -10,24 +10,28 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl ## What `--public-url` advertises -`--public-url` accepts an `http://` or `https://` root with an optional mount prefix and normalizes it to end in `/`. It supplies the printed and opened startup URL, `DSH_WEB_URL`, and the web-surface orientation. The webserver keeps serving origin-root routes and never learns the mount. The `publicUrl` config field publishes the same advertisement. +`--public-url` accepts an `http://` or `https://` root with an optional mount prefix and normalizes it to end in `/`. It supplies the printed and opened startup URL, `DSH_WEB_URL`, and the Web GUI URL in the agent's runtime context. The webserver keeps serving origin-root routes and never learns the mount. The `publicUrl` config field publishes the same advertisement. ## What the proxy must do - **Preserve the browser-facing `Host`.** The fence compares the received `Host` against the accepted authorities, so the proxy forwards it unchanged instead of rewriting it to the listener's address. - **Strip the mount prefix.** The listener answers origin-root routes, so a request for `/ui/api/...` must arrive as `/api/...`. - **Forward upgrades.** Every request and WebSocket upgrade the page opens must reach the listener with its `Upgrade` and `Connection` headers intact; TLS terminates on the proxy's external leg when it serves HTTPS. -- **Rewrite cookie scope.** The backend always issues host-only `Path=/` cookies without `Secure`; the proxy rewrites `Path` to the mount (`/ui/`) and adds `Secure` on its HTTPS leg. +- **Rewrite cookie scope.** A backend serving plain HTTP issues host-only `Path=/` cookies without `Secure`; the proxy then rewrites `Path` to the mount (`/ui/`) and adds `Secure` on its HTTPS leg. A backend listener serving its own TLS already marks the cookie `Secure`, so the proxy only rewrites `Path`. - **Redirect the bare mount.** `/ui/` is the only entry: it alone exchanges the launch token for the session cookie, and it or `/ui/index.html` serves the document to a browser that already holds that cookie, because the served document resolves URLs against its own directory. A request to `/ui` must arrive as `/ui/`, and the stripped backend cannot reconstruct that external path. ## Trust the authority browsers use -The fence accepts loopback plus every authority `--trusted-host` names. A browser that reaches the deployment under any other authority gets 403 for every API call, however correct the proxy is, so name the browser-visible authority with `--trusted-host`; advertising it with `--public-url` is display only and does not admit it. An entry without a port matches any port, which suits a tunnel that binds a different one each time. The fence only admits the request; the launch token in the printed URL and the signed session cookie authenticate it. +The fence accepts loopback, the listener's own bind address, and every authority `--trusted-host` names. A browser that reaches the deployment under any other authority gets 403 for every API call, however correct the proxy is, so name the browser-visible authority with `--trusted-host`; advertising it with `--public-url` is display only and does not admit it. An entry without a port matches any port, which suits a tunnel that binds a different one each time. The fence only admits the request; the launch token in the printed URL and the signed session cookie authenticate it. TLS, wherever it terminates, changes none of this: the certificate identifies the server to the browser, not the browser to the server. Neither the advertised URL nor the fence protects the listening port itself, so restrict the port to the trusted proxy or network. ## Secure the external leg -Terminate TLS at the proxy. An `http://` advertised root sends the launch token and the session cookie unencrypted, and an `https://` root encrypts only the browser-to-proxy leg. The printed URL carries a process credential, so share it only with intended users. +Terminate TLS at the proxy, on the listener itself, or on both; `--public-url` remains an advertisement. An `http://` browser-facing root exposes the launch token in plaintext. An `https://` proxy root protects the browser-to-proxy leg; encryption between proxy and listener depends on the listener's TLS configuration. -The [Web app reference](../../../packages/bundle/web-app/README.md#public-deployments) owns the `--public-url` and `--trusted-host` command-line contract and the `publicUrl` and `trustedHosts` fields. +To terminate on the listener, pass `--tls-cert` and `--tls-key`, each a path resolved against the process working directory: the certificate file holds the full chain and the key file an unencrypted PEM private key. DSH never obtains, renews, or watches certificates, and the carrier reads the files once, so replace them and reload the listener or restart the process to change them; startup fails on a missing or invalid pair and never falls back to HTTP. The browser applies its own trust store and name checks, so the certificate must cover the hostname or IP you open. The default port stays 3080. Native TLS needs no proxy, but proxy deployments and `--public-url` remain supported independently. A TLS listener marks session cookies `Secure`; keep the public browser-facing leg HTTPS so browsers can return those cookies. + +The printed URL carries a process credential, so share it only with intended users. + +The [Web app reference](../../../packages/bundle/web-app/README.md#public-deployments) documents the `--public-url`, `--trusted-host`, and `--tls-cert`/`--tls-key` command-line options. In the Web profile, `publicUrl` configures the `web-runtime` row, `trustedHosts` configures the [Connection row](../../../packages/client/connection/README.md), and `tls` configures the [webserver row](../../../docs/subsystems/web-server.md#config). diff --git a/docs/user/guide/public-deployments.zh.md b/docs/user/guide/public-deployments.zh.md index 54abc1e30a..93572f0db1 100644 --- a/docs/user/guide/public-deployments.zh.md +++ b/docs/user/guide/public-deployments.zh.md @@ -2,7 +2,7 @@ [English](public-deployments.md) | 中文 -`dsh --profile web` 在 loopback 端口上以明文 HTTP 提供 GUI,因此其他机器上的浏览器无法访问它,进程也无从得知你实际使用的地址。它前面的反向代理拥有这条外部链路——公开主机名、TLS,以及转发到监听器之前剥离的路径前缀——`--public-url` 则告诉 DSH 浏览器使用的是哪个地址: +`dsh --profile web` 在 loopback 端口上提供 GUI,因此其他机器上的浏览器无法访问它,进程也无从得知你实际使用的地址。它前面的反向代理拥有这条外部链路——公开主机名、TLS,以及转发到监听器之前剥离的路径前缀——`--public-url` 则告诉 DSH 浏览器使用的是哪个地址: ```sh dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example @@ -10,24 +10,28 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl ## `--public-url` 公告什么 -`--public-url` 接受可带挂载前缀的 `http://` 或 `https://` 根,并把它归一化为以 `/` 结尾。它提供打印与打开的启动 URL、`DSH_WEB_URL` 与 web 表层定位。webserver 继续提供 origin-root 路由,且从不了解挂载。`publicUrl` 配置字段发布同样的公告。 +`--public-url` 接受可带挂载前缀的 `http://` 或 `https://` 根,并把它归一化为以 `/` 结尾。它提供打印与打开的启动 URL、`DSH_WEB_URL`,以及 agent 运行时上下文中的 Web GUI URL。webserver 继续提供 origin-root 路由,且从不了解挂载。`publicUrl` 配置字段发布同样的公告。 ## 代理必须做什么 - **保留浏览器可见的 `Host`。** 栅栏把收到的 `Host` 与被接受的 authority 比对,因此代理应原样转发,而不要改写成监听器的地址。 - **剥离挂载前缀。** 监听器应答的是 origin-root 路由,因此对 `/ui/api/...` 的请求必须以 `/api/...` 抵达。 - **转发升级请求。** 页面发出的每个请求与 WebSocket 升级都必须连同其 `Upgrade` 与 `Connection` 头抵达监听器;代理对外提供 HTTPS 时,TLS 在其外部链路上终止。 -- **改写 cookie 作用域。** 后端始终签发不带 `Secure` 的 host-only `Path=/` cookie;代理把 `Path` 改写为挂载(`/ui/`),并仅在其 HTTPS 链路上添加 `Secure`。 +- **改写 cookie 作用域。** 以明文 HTTP 提供服务的后端签发不带 `Secure` 的 host-only `Path=/` cookie;代理再把 `Path` 改写为挂载(`/ui/`),并仅在其 HTTPS 链路上添加 `Secure`。监听器自身提供 TLS 时 cookie 已带 `Secure`,代理只需改写 `Path`。 - **重定向裸挂载。** `/ui/` 是唯一入口:只有它把启动 token 换成会话 cookie;已持有该 cookie 的浏览器可由它或 `/ui/index.html` 取得文档,因为所提供的文档以自身目录解析 URL。对 `/ui` 的请求必须以 `/ui/` 抵达,而被剥离路径的后端无法重建该外部路径。 ## 信任浏览器使用的 authority -栅栏接受 loopback,以及每个由 `--trusted-host` 点名的 authority。浏览器若以其他任何 authority 访问部署,无论代理多么正确,每个 API 调用都会得到 403,因此请用 `--trusted-host` 点名浏览器可见的 authority;用 `--public-url` 公告它只是展示,并不等于接纳。不带端口的条目匹配任意端口,适合每次绑定不同端口的隧道。栅栏只放行请求;打印 URL 中的启动 token 与签名会话 cookie 才完成认证。 +栅栏接受 loopback、监听器自身的绑定地址,以及每个由 `--trusted-host` 点名的 authority。浏览器若以其他任何 authority 访问部署,无论代理多么正确,每个 API 调用都会得到 403,因此请用 `--trusted-host` 点名浏览器可见的 authority;用 `--public-url` 公告它只是展示,并不等于接纳。不带端口的条目匹配任意端口,适合每次绑定不同端口的隧道。栅栏只放行请求;打印 URL 中的启动 token 与签名会话 cookie 才完成认证。无论 TLS 在哪里终止都不改变这一切:证书只是向浏览器标识服务器,而不是向服务器标识浏览器。 无论是公告 URL 还是栅栏都不保护监听端口本身,因此请把端口限制在可信代理或网络内。 ## 保护外部链路 -在代理处终止 TLS。`http://` 公告根会以明文发送启动 token 与会话 cookie,而 `https://` 根只加密浏览器到代理这一段。打印的 URL 携带进程凭据,只应与预期用户分享。 +可以在代理处、监听器本身或两者上终止 TLS;`--public-url` 仍只是公告。面向浏览器的 `http://` 根会明文暴露启动 token。`https://` 代理根保护浏览器到代理这一段;代理与监听器之间是否加密取决于监听器的 TLS 配置。 -[Web 应用参考](../../../packages/bundle/web-app/README.zh.md#public-deployments)负责 `--public-url` 与 `--trusted-host` 的命令行约定,以及 `publicUrl` 与 `trustedHosts` 字段。 +要在监听器上终止 TLS,请传入 `--tls-cert` 与 `--tls-key`,两者各是相对进程工作目录解析的路径:证书文件包含完整证书链,密钥文件为不带口令的 PEM 私钥。DSH 从不获取、续期或监视证书,且载体只读取一次文件,因此更换文件后需重新加载监听器或重启进程;文件缺失或无效时启动失败,绝不回退到 HTTP。浏览器使用自己的信任库与名称校验,因此证书必须覆盖你所访问的 hostname 或 IP。默认端口仍为 3080。原生 TLS 不需要代理,但代理部署与 `--public-url` 仍分别受支持。TLS 监听器会给会话 cookie 加上 `Secure`;公开部署的浏览器侧链路应保持 HTTPS,才能让浏览器回传这些 cookie。 + +打印的 URL 携带进程凭据,只应与预期用户分享。 + +[Web 应用参考](../../../packages/bundle/web-app/README.zh.md#public-deployments)说明 `--public-url`、`--trusted-host` 与 `--tls-cert`/`--tls-key` 命令行选项。在 Web profile 中,`publicUrl` 配置在 `web-runtime` 行,`trustedHosts` 配置在 [Connection 行](../../../packages/client/connection/README.zh.md),`tls` 配置在 [webserver 行](../../../docs/subsystems/web-server.zh.md#config)。 diff --git a/lefthook.yml b/lefthook.yml index 1d5c8898c6..75c03f9a26 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -21,16 +21,6 @@ pre-commit: run: node_modules/.bin/tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json --fix --no-error-on-unmatched-pattern {staged_files} stage_fixed: true - # Regenerate rather than reject: a dependency edit that forgot the notices - # would otherwise fail the test lane long after the commit. The glob matches - # every input the generator reads, including the generator itself and the - # build-time pin source. Deleting a manifest cannot trigger this job — - # lefthook only inspects files present on disk — so that one case still - # falls through to the freshness assertion in the test lane. - - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/primary-runtime/lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' - run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - - name: whitespace (staged) run: git diff --cached --check diff --git a/package.json b/package.json index 837bf1d905..3b9e5f2b36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "license": "MIT", "private": true, "type": "module", @@ -19,6 +19,8 @@ "scripts": { "prepare:primary-runtime": "tsx scripts/primary-runtime/prepare.ts", "build": "tsx scripts/build.ts", + "build:artifacts": "tsx scripts/build.ts --scope artifacts", + "build:check": "tsx scripts/build.ts --scope check", "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", @@ -48,11 +50,11 @@ "upload:latest:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:win:x64", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "npm run build:lib:host && npm run typecheck:contracts-ready", + "typecheck": "tsx scripts/build.ts --scope typecheck", "typecheck:contracts-ready": "tsc -b tsconfig.client.json", - "lint": "npm run build:lib:host && npm run lint:contracts-ready", + "lint": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run lint:contracts-ready", "lint:contracts-ready": "tsx scripts/run-oxlint.ts .", - "lint:fix": "npm run build:lib:host && npm run lint:fix:contracts-ready", + "lint:fix": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run lint:fix:contracts-ready", "lint:fix:contracts-ready": "tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json packages/typert/generator/tests/fixtures/type-model --fix && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "pnpm run build:native-system && vitest run", @@ -99,7 +101,7 @@ "check:ci:windows-observational-ready": "tsx scripts/run-gates.ts ci-windows-observational-ready", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "publint": "tsx scripts/publint-all.ts", - "doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready", + "doc-typecheck": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run doc-typecheck:contracts-ready", "doc-typecheck:contracts-ready": "tsx scripts/doc-typecheck.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", @@ -238,7 +240,10 @@ "@types/js-yaml": "^4.0.9", "@types/jsdom": "^28.0.3", "@types/mdast": "^4.0.4", + "@types/papaparse": "^5.5.2", "@types/node": "^22.20.1", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "@types/semver": "^7.8.0", "@types/spdx-expression-parse": "^4.0.0", "@vitest/coverage-v8": "^4.1.11", @@ -246,6 +251,7 @@ "@yarnpkg/cli-dist": "4.18.0", "eslint-plugin-sonarjs": "^4.2.0", "execa": "^10.0.1", + "zod": "^4.4.3", "extract-zip": "^2.0.1", "fast-check": "^4.9.0", "fflate": "^0.8.2", @@ -261,6 +267,7 @@ "micromark-extension-gfm": "^3.0.0", "oxlint": "1.81.0", "oxlint-tsgolint": "7.0.2001", + "papaparse": "5.5.3", "pnpm": "11.28.5", "publint": "^0.3.24", "semver": "^7.8.5", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 8c071dac2e..50f203586c 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -10,6 +10,8 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Design Service Definitions for all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`). - **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service. - **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice. +- **Give shared state an owner.** Hang shared state on a service or instance. A module-level cache, a singleton, a global `WeakMap`, or a `Symbol.for` / `globalThis` / `window.__X__` bypass hides that owner; add no new global `current`, `active`, or `selection` singleton, and derive context from the current scope instead. +- **Keep business rules in their own package.** A runtime, controller, manager, gateway, or API dispatch layer carries generic orchestration; a business branch belongs to an independent package and attaches through a registration surface or projected data ([where new behavior goes](../docs/architecture.md#where-new-behavior-goes)). - **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage. - **Enforce a decision in the operation that makes it.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. - **Publish state only at its commit point.** Emit each notification and update derived state only after the operation succeeds; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 5a9e332ce9..ee019fe922 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/account-controller/locale/en.json b/packages/api/account-controller/locale/en.json new file mode 100644 index 0000000000..4c7e6dd036 --- /dev/null +++ b/packages/api/account-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-account-controller" + } +} diff --git a/packages/api/account-controller/locale/zh.json b/packages/api/account-controller/locale/zh.json new file mode 100644 index 0000000000..f404257eaa --- /dev/null +++ b/packages/api/account-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "账号操作接口", + "description": "向已认证的客户端提供账号操作能力。" + } +} diff --git a/packages/api/account-controller/package.json b/packages/api/account-controller/package.json index ae3bfe24a1..fd3a002a5d 100644 --- a/packages/api/account-controller/package.json +++ b/packages/api/account-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-account-controller", "description": "Expose safe account operations over authenticated Remote", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -31,9 +31,11 @@ "./remote": { "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" - } + }, + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", @@ -44,7 +46,7 @@ ], "license": "MIT", "dependencies": { - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index e8cf3b7f4b..5be8cde9cc 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -15,8 +15,8 @@ en: d9859219cdb0f367 zh: 2a3cfa8cb786e327 /deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway: - en: 5179435c09004549 - zh: c904462396531947 + en: 2aabd26516a45771 + zh: 5907f472a4d536df /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: c106f0ca3e547f84 zh: 833e30d7151c41c7 @@ -27,8 +27,8 @@ en: af5abeee32f2d93b zh: b320bb727a223a13 /deepseek-ai-dsh-api-gateway/known-limitations-and-deferred-work: - en: 3ade2903f40a0a98 - zh: 9af1d557fefdbd89 + en: 5af0d863582fbc4f + zh: d87e4b97b579c456 /deepseek-ai-dsh-api-gateway/known-limitations-and-deferred-work/dev-note: en: 10727471030aee01 zh: e6bee82abd12127d diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 41cfc3a324..c2d896972e 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. -A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A Pong, any received frame, or a downlink write that waited in the full local send buffer and then completed counts as peer activity; a socket with no activity across two Ping intervals is terminated, so a congested link that delays the Pong behind data stays connected. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgrade route only after successful application startup. Earlier connection attempts remain carrier failures handled by Connection's retry policy, so a restarting Host cannot accept streams while its controllers are still initializing. Unloading cancels a pending readiness subscription and closes any registered carrier. Embedded Hosts without `appReady` register immediately and own their startup ordering; in-process invocation is unchanged. @@ -86,7 +86,8 @@ No direct effect; invoked business Services own any model-visible result. - `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. -- `websocketHeartbeatIntervalMs` is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it. +- `websocketHeartbeatIntervalMs` is both the Ping cadence and the activity deadline. The Host terminates a peer that shows no activity for two intervals, so a deployment whose event loop or network can stall longer than this interval must raise it. +- A completed queued write shows that the TCP peer acknowledged data. Behind a buffering reverse proxy that peer is the proxy, so a vanished Client during sustained downlink is detected only after the proxy stops accepting data. - Uplinks have no flow control beyond the bounded Host inbox: a Client that sends faster than the method reads, or that sends to a method never taking its uplink, fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index c73b0f45b6..d92ae276f6 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。Pong、收到的任意帧,或曾在已满的本机发送缓冲区中等待后完成的下行写入,都视为对端活动;连续两个 Ping 间隔没有活动的 socket 会被终止,因此 Pong 被数据阻塞在拥塞链路上时连接仍会保持。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 启动器提供 `ctx.appReady` 时,Gateway 仅在应用成功启动后注册 WebSocket 升级路由。此前的连接尝试仍属于载体故障,由 Connection 的重试策略处理,因此重启中的 Host 不会在控制器仍在初始化时接受流。卸载会取消尚未触发的就绪订阅,并关闭已注册的载体。不提供 `appReady` 的嵌入式 Host 会立即注册,并自行负责启动顺序;进程内调用不变。 @@ -86,7 +86,8 @@ Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 - `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events` 流;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 - lookup 解析器按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 -- `websocketHeartbeatIntervalMs` 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 +- `websocketHeartbeatIntervalMs` 同时是 Ping 周期和活动截止时间。对端连续两个周期没有活动时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 +- 排队写入完成只说明 TCP 对端确认了数据。经过带缓冲的反向代理时,该对端是代理,因此持续下行期间 Client 消失,要等代理不再接收数据后才会被检测到。 - 上行除了有界的 Host inbox 之外没有流控:Client 发送快于方法读取,或发给从未取用 uplink 的方法时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。 diff --git a/packages/api/gateway/locale/en.json b/packages/api/gateway/locale/en.json new file mode 100644 index 0000000000..3aaa997318 --- /dev/null +++ b/packages/api/gateway/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-gateway" + } +} diff --git a/packages/api/gateway/locale/zh.json b/packages/api/gateway/locale/zh.json new file mode 100644 index 0000000000..616ee05866 --- /dev/null +++ b/packages/api/gateway/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "远程调用网关", + "description": "接收客户端的接口调用,并分发给对应的服务。" + } +} diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 0ee56662b0..d4469cf8cd 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -31,7 +31,8 @@ "./stream-protocol": { "types": "./lib/types/stream-protocol.d.ts", "default": "./lib/types/stream-protocol.js" - } + }, + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -48,6 +49,7 @@ "watch": "tsdown --watch" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -75,6 +77,6 @@ "@deepseek-ai/dsh-util-crypto": "workspace:*", "@types/ws": "^8.18.1", "@deepseek-ai/cordis": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" } } diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 9454f03f2d..34074722da 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -72,12 +72,12 @@ export class RemoteStreamMuxServer { this.server.handleUpgrade(req, socket, head, (websocket) => { const release = bindPeer(websocket, peer) if (release === undefined) return - this.missedHeartbeats.set(websocket, 0) - websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) }) + const alive = (): void => { this.missedHeartbeats.set(websocket, 0) } + alive() this.startHeartbeat() const bound: BoundStreamOpener = (endpoint, payload, uplink, control) => this.open(endpoint, payload, uplink, peer, control) - const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes) + const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes, alive) const done = connection.run() this.connections.add(done) void done.then(() => { @@ -141,13 +141,23 @@ class RemoteStreamMuxConnection { private readonly open: BoundStreamOpener, private readonly failure: RemoteStreamFailureMapper, private readonly streamInboxBytes: number, + private readonly alive: () => void, ) {} + /** + * Serve frames until the socket closes. A Pong, any received frame, and a + * downlink write that waited in the local send buffer and then completed + * count as peer activity, so a congested link that delays the Pong behind + * data keeps its socket. A write that completes at once proves nothing + * about the peer and does not count. + */ async run(): Promise { const closed = new Promise((resolve) => { this.socket.once('close', resolve) this.socket.once('error', () => { this.socket.terminate() }) + this.socket.on('pong', this.alive) this.socket.on('message', (data, isBinary) => { + this.alive() if (isBinary) { this.socket.close(1003, 'text messages required') return @@ -283,10 +293,18 @@ class RemoteStreamMuxConnection { reject(new Error('api gateway: Remote stream socket is closed')) return } + let queued = false this.socket.send(text, (error) => { - if (error) reject(error) - else resolve() + if (error) { + reject(error) + return + } + if (queued) this.alive() + resolve() }) + // Read after send(): bytes still buffered mean the local send buffer was + // full, so their completion requires the peer to acknowledge data. + queued = this.socket.bufferedAmount > 0 })) this.writes = delivery.catch(() => undefined) return delivery diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 469aa25b0c..965cbb7989 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -67,6 +67,54 @@ describe('Remote stream mux server carrier lifecycle', () => { await closed }) + it('counts inbound frames as liveness while Pong is delayed', async () => { + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) + const client = await connect(entry.url, false) + const terminated = vi.spyOn(acceptedSocket(entry.mux), 'terminate') + const traffic = setInterval(() => { client.send(JSON.stringify({ type: 'cancel', streamId: 'absent' })) }, 5) + try { + await pings(client, 4) + expect(terminated).not.toHaveBeenCalled() + } finally { + clearInterval(traffic) + } + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + }) + + it('counts a downlink write that waited in the send buffer as liveness', async () => { + const stop = Promise.withResolvers() + const entry = await startMux(async () => steadyItems(stop.promise), 20) + const client = await connect(entry.url, false) + const serverSocket = acceptedSocket(entry.mux) + const terminated = vi.spyOn(serverSocket, 'terminate') + Object.defineProperty(serverSocket, 'bufferedAmount', { configurable: true, value: 1 }) + client.send(openFrame('congested')) + try { + await pings(client, 4) + expect(terminated).not.toHaveBeenCalled() + } finally { + stop.resolve(undefined) + Reflect.deleteProperty(serverSocket, 'bufferedAmount') + } + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + }) + + it('does not count a downlink write that completed at once as liveness', async () => { + const stop = Promise.withResolvers() + const entry = await startMux(async () => steadyItems(stop.promise), 20) + const client = await connect(entry.url, false) + const terminated = vi.spyOn(acceptedSocket(entry.mux), 'terminate') + const items = vi.fn() + client.on('message', items) + client.send(openFrame('flowing')) + try { + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + expect(items).toHaveBeenCalled() + } finally { + stop.resolve(undefined) + } + }) + it('keeps the socket when a delayed Pong arrives before the final check', async () => { const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url, false) @@ -585,6 +633,19 @@ async function *echoUplink(uplink: AsyncIterable): AsyncIterable): AsyncIterable { + const stopped = stop.then(() => true) + while (!await Promise.race([stopped, new Promise((resolve) => { setTimeout(() => { resolve(false) }, 5) })])) { + yield 'item' + } +} + +/** Resolve after the client has received `count` Ping frames. */ +async function pings(client: WebSocket, count: number): Promise { + for (let received = 0; received < count; received++) await once(client, 'ping') +} + async function *waitForAbort(signal: AbortSignal): AsyncIterable { await new Promise((resolve) => { if (signal.aborted) resolve() diff --git a/packages/api/gateway/tsdown.config.ts b/packages/api/gateway/tsdown.config.ts index 2f199f0d1f..71d5bd77f8 100644 --- a/packages/api/gateway/tsdown.config.ts +++ b/packages/api/gateway/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/api/job-controller/locale/en.json b/packages/api/job-controller/locale/en.json new file mode 100644 index 0000000000..87cdb55a70 --- /dev/null +++ b/packages/api/job-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-job-controller" + } +} diff --git a/packages/api/job-controller/locale/zh.json b/packages/api/job-controller/locale/zh.json new file mode 100644 index 0000000000..1d7b6c6e65 --- /dev/null +++ b/packages/api/job-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "后台任务观察接口", + "description": "向客户端持续提供后台任务的状态和输出。" + } +} diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json index f9fd59429b..a82912d8bf 100644 --- a/packages/api/job-controller/package.json +++ b/packages/api/job-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-job-controller", "description": "Job Remote observation stream and the reference-counted client job-output service", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -35,7 +35,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -54,6 +55,7 @@ "watch": "tsdown --watch" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -67,7 +69,7 @@ "dependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/api/job-controller/tsdown.config.ts b/packages/api/job-controller/tsdown.config.ts index 0391bc61bf..0e42a1163e 100644 --- a/packages/api/job-controller/tsdown.config.ts +++ b/packages/api/job-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-job-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/remotes/locale/en.json b/packages/api/remotes/locale/en.json new file mode 100644 index 0000000000..cc9009975d --- /dev/null +++ b/packages/api/remotes/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-remotes" + } +} diff --git a/packages/api/remotes/locale/zh.json b/packages/api/remotes/locale/zh.json new file mode 100644 index 0000000000..222afebaf9 --- /dev/null +++ b/packages/api/remotes/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "客户端 API 汇总", + "description": "汇总应用向客户端开放的服务端能力。" + } +} diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index f09b9a96ae..1ac78ce12f 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -27,7 +27,8 @@ "default": "./lib/types/types.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -44,6 +45,7 @@ }, "license": "MIT", "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -86,7 +88,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "zod": "^4.4.3", + "zod": "4.4.3", "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-api-terminal-controller": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts index 29349148d6..0e42a1163e 100644 --- a/packages/api/remotes/tsdown.config.ts +++ b/packages/api/remotes/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-remotes', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 18627b3c99..3d1a5b274d 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: ae2715276ec70675 - zh: 0937d1335c21eff2 + en: d8c0e2b2e45fa75d + zh: 025b50be4204580c /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index d5a7f2a7a7..ed01827a70 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -31,13 +31,15 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. + +List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints. Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. -Explicit-id `session.create` adopts a live Session or resumes a persisted Session while retaining its writer lock. Writer contention returns `session/writer-held`; callers may try another blank without suppressing unrelated failures. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. +`session.create` returns a Session for a new conversation, and its response names the Session actually used. A named `sessionId` is reused while it is blank (no turn has started), belongs to the requested directory, is an unarchived member of the requested Workspace, and is not written by another owner; a live Session is reused in place, and a persisted one is resumed while retaining its writer lock. The reused Session runs the requested `agentPreset`, or the configured default when the request names none: its recorded preset is not resolved, and a change is logged as `agent-preset/selected` before the Agent is published. A named Session that has started, fails the Workspace placement, or is written by another owner stays unchanged, and a new Session is created instead. A `sessionId` with no stored Session becomes the new Session's identity. A named Session in another directory fails with `session/conflict`, and one owned by subagent routing with `session/agent-busy`; both checks precede preset resolution. Creations naming one identity run in arrival order, including their preset resolution, and each response reports the preset its own request left the Session running. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index c6c7f51633..cc5a4ce4b2 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -31,13 +31,15 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 + +列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。 Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 -显式 ID 的 `session.create` 会收养活动 Session,或恢复持久化 Session 并持续持有其写锁。写锁争用返回 `session/writer-held`;调用方可以尝试其他空白会话,同时保留其他失败。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 +`session.create` 返回一个用于新对话的 Session,响应指明实际使用的 Session。请求指明的 `sessionId` 在满足以下条件时被复用:仍为空白(尚未开始任何轮次)、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用;活动 Session 原地复用,持久化 Session 被恢复并持续持有其写锁。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset:不解析它记录的 preset,变更在 Agent 发布前记录为 `agent-preset/selected`。指明的 Session 若已开始、不满足工作区归属,或被其他写者占用,则保持不变,改为新建 Session。没有对应已存储 Session 的 `sessionId` 成为新 Session 的标识。指明其他目录中的 Session 返回 `session/conflict`,指明由 subagent 路由拥有的 Session 返回 `session/agent-busy`;这两项检查先于 preset 解析。指明同一标识的创建请求按到达顺序执行,preset 解析也包含在内,每个响应报告其自身请求完成时该 Session 运行的 preset。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 diff --git a/packages/api/session-controller/locale/en.json b/packages/api/session-controller/locale/en.json new file mode 100644 index 0000000000..3be8df2f2f --- /dev/null +++ b/packages/api/session-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-session-controller" + } +} diff --git a/packages/api/session-controller/locale/zh.json b/packages/api/session-controller/locale/zh.json new file mode 100644 index 0000000000..48389bed43 --- /dev/null +++ b/packages/api/session-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会话控制接口", + "description": "向客户端提供会话读取、运行控制和状态更新能力。" + } +} diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 3f421fe675..436b151bbb 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -39,7 +39,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -58,6 +59,7 @@ "watch": "tsdown --watch" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -73,7 +75,7 @@ "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "mime-types": "^3.0.2", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -144,6 +146,7 @@ "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-session-projection-cache": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 3f0fa77944..aaacc886e7 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -1,5 +1,6 @@ /** Agent activation, composition, and model-selection policy owned by API Session. */ +import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' @@ -8,11 +9,12 @@ import type { } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-agent-preset-registry' +import { brandString } from '@deepseek-ai/dsh-brand' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' import type { ModelSelection } from './types.ts' @@ -42,21 +44,49 @@ export class ApiSessionCwdConflict extends Error { } } -/** Explicit-id creation attempted to adopt a Session under another preset. */ +/** Reuse of a Session named an Agent preset while the deployment composes no preset roster. */ export class ApiSessionPresetConflict extends Error { constructor( readonly sessionId: SessionId, readonly requestedPreset: string, - readonly existingPreset: string | undefined, ) { - super( - existingPreset === undefined - ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"` - : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`, - ) + super(`the deployment composes no agent preset roster, so session "${sessionId}" cannot run preset "${requestedPreset}"`) } } +/** A blank Session chosen for reuse started a turn before this Host acquired its writer. */ +class ApiSessionStartedSinceObserved extends Error {} + +/** Placement and Agent preset for one Session created for a new conversation. */ +export interface ApiSessionCreateRequest { + /** Blank Session to reuse, or the identity of a new Session when none is stored under it. */ + readonly sessionId?: SessionId + /** Directory the Session owns. */ + readonly cwd: string + /** Preset the Session runs; the configured default when omitted. */ + readonly agentPreset?: string + /** + * Placement rule the caller owns, such as Workspace membership and archive state. + * @param sessionId - existing Session named by the request. + * @returns whether that Session may be reused. + */ + readonly reusable: (sessionId: SessionId) => boolean +} + +/** The Session one creation request created or reused. */ +export interface ApiSessionCreated { + /** Live Agent of the Session. */ + readonly agent: Agent + /** Preset the Session ran when the request finished, before a later request for the same identity; absent without a roster. */ + readonly agentPreset: string | undefined +} + +/** Resolved preset and pre-publication Agent setup for one create or resume. */ +interface ApiSessionComposition { + readonly agentPreset?: string + readonly setup: AgentSetup +} + /** Failures produced while resolving one ordinary Session identity to its live Agent. */ export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'session/writer-held' | 'gateway/internal'> @@ -139,7 +169,7 @@ export async function inspectApiSession( /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { private readonly resumes = new Map>() - private readonly creations = new Map>() + private readonly claims = new Map>() private readonly selections = new WeakMap() private readonly imageAdmissionChains = new WeakMap>() @@ -229,50 +259,26 @@ export class ApiSessionAgentController { } /** - * Resolve one requested identity, creating or resuming it once. - * @param sessionId - requested Session identity. - * @param cwd - directory the Session must own. - * @param checkPersistedIdentity - whether to inspect a cold identity before creation. - * @param presetId - optional Agent preset the Session must own. - * @returns the matching live ordinary Agent. + * Create a Session for a new conversation, reusing the named blank Session + * while it is still reusable. A reused Session runs the requested preset, or + * the configured default when the request names none; the preset it recorded + * is not resolved, and a change is logged as `agent-preset/selected` before + * the Agent is published. A named Session that has started a turn, fails the + * caller's placement rule, or is written by another owner stays unchanged, + * and a new Session is created instead. Requests naming one identity run in + * arrival order, including their preset resolution, and check the named + * Session's directory and ownership before resolving the preset. + * @param request - optional Session to reuse, directory, preset, and placement rule. + * @returns the reused or created Session and the preset it ran when this request finished. + * @throws ApiSessionCwdConflict when the named Session belongs to another directory. + * @throws ApiSessionSubagentOwnership when subagent routing owns the named Session. + * @throws RemoteError `agent-preset/not-found` or `agent-preset/invalid` for the requested or default preset. + * @throws ApiSessionPresetConflict when no roster is composed and the request names a preset for a reused Session. */ - async ensureSession( - sessionId: SessionId, - cwd: string, - checkPersistedIdentity: boolean, - presetId?: string, - ): Promise { - let creation = this.creations.get(sessionId) - if (creation === undefined) { - creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId) - .catch((error: unknown) => { - const live = this.ctx.agents.get(sessionId) - if (live !== undefined) { - if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - return live - } - const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - throw error - }) - .finally(() => { this.creations.delete(sessionId) }) - this.creations.set(sessionId, creation) - } - const agent = await creation - if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - if (presetId !== undefined) { - this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session)) - } - if (agent.session.header.cwd !== cwd) { - throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd) - } - return agent + async createSession(request: ApiSessionCreateRequest): Promise { + const named = request.sessionId + if (named === undefined) return this.created(await this.createMinted(request.cwd, await this.composeAgent(request.agentPreset))) + return this.inOrder(named, async () => this.created(await this.claim(named, request))) } /** @@ -378,10 +384,7 @@ export class ApiSessionAgentController { * @param presetId - requested preset or the configured default when omitted. * @returns the resolved preset identity and Agent setup callback. */ - async composeAgent(presetId: string | undefined): Promise<{ - readonly agentPreset?: string - readonly setup: AgentSetup - }> { + async composeAgent(presetId: string | undefined): Promise { const presets = this.ctx.get('agentPresets') if (presets === undefined) { return { setup: (_agentCtx, agent) => { this.installSelection(agent) } } @@ -441,48 +444,128 @@ export class ApiSessionAgentController { })).agent } - private async createOrAdopt( - sessionId: SessionId, - cwd: string, - checkPersistedIdentity: boolean, - presetId: string | undefined, - ): Promise { + private async inOrder(sessionId: SessionId, operation: () => Promise): Promise { + const result = (this.claims.get(sessionId) ?? Promise.resolve()).then(operation) + const settled = result.then(() => undefined, () => undefined) + this.claims.set(sessionId, settled) + try { + return await result + } finally { + if (this.claims.get(sessionId) === settled) this.claims.delete(sessionId) + } + } + + /** @returns the reused Session, a new Session under `sessionId` when none is stored, or a new Session under a minted identity. */ + private async claim(sessionId: SessionId, request: ApiSessionCreateRequest): Promise { const attached = this.ctx.sessions.get(sessionId) const live = this.ctx.agents.get(sessionId) if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { throw new ApiSessionSubagentOwnership(sessionId) } - if (live !== undefined) return live - - if (checkPersistedIdentity) { - try { - using observation = await this.ctx.sessionQuery.observeSession(sessionId) - if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - if (observation.header.cwd !== cwd) { - throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd) - } - const storedPreset = this.presetForObservation(observation) - this.assertPresetUnchanged(sessionId, presetId, storedPreset) - const composition = await this.composeAgent(storedPreset) - return (await this.ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: this.agentOptions(), - setup: composition.setup, - })).agent - } catch (error: unknown) { - if (!(error instanceof SessionQueryError) - || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error + if (live !== undefined) { + if (live.session.header.cwd !== request.cwd) { + throw new ApiSessionCwdConflict(live.id, request.cwd, live.session.header.cwd) } + const composition = await this.composeAgent(request.agentPreset) + return await this.reuseLive(live, request, composition) ?? this.createMinted(request.cwd, composition) } + let observation: SessionObservation + try { + observation = await this.ctx.sessionQuery.observeSession(sessionId) + } catch (error: unknown) { + if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error + return this.createAgent(sessionId, request.cwd, await this.composeAgent(request.agentPreset)) + } + let reusable: boolean + { + using observed = observation + if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + if (observed.header.cwd !== request.cwd) { + throw new ApiSessionCwdConflict(sessionId, request.cwd, observed.header.cwd) + } + reusable = request.reusable(sessionId) && !observed.events.some(event => event.type === 'turn/start') + } + const composition = await this.composeAgent(request.agentPreset) + if (!reusable) return this.createMinted(request.cwd, composition) + this.assertPresetApplicable(sessionId, request.agentPreset) + try { + return (await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: this.reuseSetup(composition), + })).agent + } catch (error: unknown) { + if (error instanceof ApiSessionStartedSinceObserved) return this.createMinted(request.cwd, composition) + if (!(error instanceof Error) || error.name !== 'SessionAlreadyOwnedError') throw error + // Another resume in this process may hold the writer for the Agent it published. + return this.ctx.agents.get(sessionId) === undefined + ? this.createMinted(request.cwd, composition) + : this.claim(sessionId, request) + } + } + /** @returns the live blank Session under the requested preset, or undefined when it cannot be reused. */ + private async reuseLive( + live: Agent, + request: ApiSessionCreateRequest, + composition: ApiSessionComposition, + ): Promise { + if (!request.reusable(live.id) || !this.isBlank(live.session)) return undefined + this.assertPresetApplicable(live.id, request.agentPreset) + const current = this.presetForSession(live.session) + const presets = this.ctx.get('agentPresets') + const target = composition.agentPreset + if (presets === undefined || target === undefined || target === current) return live + try { + await presets.select(live, target) + } catch (error: unknown) { + if (remoteErrorOf(error)?.code !== 'agent-preset/locked') throw error + return undefined + } + return live + } + + /** Setup that re-checks blankness under the acquired writer, then records a preset change before publication. */ + private reuseSetup(composition: ApiSessionComposition): AgentSetup { + return async (agentCtx, agent) => { + if (!this.isBlank(agent.session)) throw new ApiSessionStartedSinceObserved() + const commit = await composition.setup(agentCtx, agent) + const target = composition.agentPreset + if (target !== undefined && this.presetForSession(agent.session) !== target) { + agent.session.append('agent-preset/selected', { agentPreset: target }) + } + return commit + } + } + + /** Whether no turn has started, by the predicate the preset registry's `select` enforces. */ + private isBlank(session: Session): boolean { + const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary') + return boundary === undefined || (boundary.openTurnStartSeq === null && boundary.lastTurn === 0) + } + + /** Without a roster, reuse cannot apply a named preset. */ + private assertPresetApplicable(sessionId: SessionId, requested: string | undefined): void { + if (requested === undefined || this.ctx.get('agentPresets') !== undefined) return + throw new ApiSessionPresetConflict(sessionId, requested) + } + + private created(agent: Agent): ApiSessionCreated { + return { agent, agentPreset: this.presetForSession(agent.session) } + } + + private createMinted(cwd: string, composition: ApiSessionComposition): Promise { + return this.createAgent(brandString(`session-${randomUUID()}`), cwd, composition) + } + + private async createAgent(sessionId: SessionId, cwd: string, composition: ApiSessionComposition): Promise { try { await mkdir(cwd, { recursive: true }) } catch (error: unknown) { throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) } - const composition = await this.composeAgent(presetId) return (await this.ctx.agents.create({ sessionId, agentOptions: this.agentOptions(), @@ -514,15 +597,6 @@ export class ApiSessionAgentController { } return observation.projections.values.agentPreset ?? undefined } - - private assertPresetUnchanged( - sessionId: SessionId, - requested: string | undefined, - existing: string | undefined, - ): void { - if (requested === undefined || requested === existing) return - throw new ApiSessionPresetConflict(sessionId, requested, existing) - } } function agentModelSelection(selection: ModelSelection): AgentModelSelection { diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 076188c1e0..a52d2c155a 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -79,24 +79,27 @@ export interface ISessions { */ readonly searchResultLimit: number /** - * Create or adopt a Session on the Host. - * @param opts - target workspace, directory, and optional preallocated identity. - * @returns the catalogued identity; retain it before borrowing its binding. + * Create a Session for a new conversation on the Host. The Host reuses the + * named blank Session while it is reusable and otherwise creates another; the + * Session runs the named preset, or the Host default when none is named. + * @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the catalogued identity the Host created or reused; retain it before borrowing its binding. */ create(opts?: { workspaceId?: WorkspaceId cwd?: string sessionId?: SessionId + agentPreset?: string }): Promise /** * Resolve an already discovered direct-parent address without opening it. * @param id - possible addressed child id. - * @returns a retained or loaded-catalog address, without retaining a new selection or scope. + * @returns a retained, catalog-derived, or unknown-mode listed address, without retaining a new selection or scope. */ subagentAddress(id: SessionId): SubagentAddress | undefined /** - * Load all Session projections once per connection; retry an unsuccessful initial read. + * Read projections without starting migration; retry failed or migration-deferred reads. * @param sessionId - Session to inspect without opening its conversation. * @returns completion of the current or newly started refresh. */ @@ -134,6 +137,8 @@ export interface ISessions { atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void + /** False refuses source migration; omission preserves the Host default. */ + allowMigration?: boolean }): Promise /** * Borrow an already-retained Agent-scoped Context without extending its lifetime. diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index b0b49311f3..e74cd9dce5 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -27,6 +27,8 @@ export interface SessionListEntry { parentSessionId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' + /** Format classification supplied by the Host list. */ + formatStatus?: SessionSummary['formatStatus'] cwd?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index db7236c1b2..b8bec9be8a 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -61,7 +61,7 @@ export interface SessionListSnapshot { /** Shared projection values and the lifecycle of their explicit baseline read. */ export interface SessionProjectionSnapshot { readonly values: Readonly> - readonly state: 'idle' | 'loading' | 'ready' | 'error' + readonly state: 'idle' | 'loading' | 'ready' | 'migration-required' | 'error' readonly error: RemoteFailure | null } @@ -153,8 +153,9 @@ export class SessionManager { /** * Resolve an address for breadcrumb navigation without retaining transport authority. - * @param sessionId - possible child id in an already-loaded catalog. - * @returns A retained or catalog-derived direct-parent address. + * @param sessionId - possible child id in received Session metadata. + * @returns A retained address, then a catalog address, then an unknown-mode address + * from a listed subagent header; undefined without that evidence. */ subagentAddress(sessionId: SessionId): SubagentAddress | undefined { const retained = this.addresses.get(sessionId) @@ -170,7 +171,13 @@ export class SessionManager { } } } - return undefined + const summary = this.summaries.find(summary => summary.sessionId === sessionId) + if (summary?.origin !== 'subagent' || summary.parentSessionId === undefined) return undefined + return { + parentSessionId: summary.parentSessionId, + childSessionId: sessionId, + mode: 'unknown', + } } // ---- Instance management ---- @@ -332,7 +339,7 @@ export class SessionManager { } /** - * Load a complete projection baseline once per connection; retry unsuccessful reads. + * Read projections without starting migration; retry failed or migration-deferred reads. * @param sessionId - Session to inspect without opening its conversation. * @returns completion of the current or newly started read. */ @@ -350,13 +357,26 @@ export class SessionManager { const result = await this.remote.session.projections({ sessionId }, controller.signal) if (controller.signal.aborted) return if (result.ok) { - if (result.value !== null) { - store.seed({ ...result.value, asOfSeq: sessionSeqCursor(result.value.asOfSeq) }) + const value = result.value + if (value !== null) { + switch (value.kind) { + case 'sequenced': + store.seed({ values: value.values, asOfSeq: sessionSeqCursor(value.asOfSeq) }) + break + case 'migration-required': + store.applyCached(value.values) + break + default: + assertNever(value, 'session projections result') + } } else if (store.values() === initialValues) { // A later frame proves existence independently of an earlier missing read. store.clear() } - this.projectionLoads.set(sessionId, { state: 'ready', error: null }) + this.projectionLoads.set(sessionId, { + state: value?.kind === 'migration-required' ? 'migration-required' : 'ready', + error: null, + }) } else { this.projectionLoads.set(sessionId, { state: 'error', error: result.error }) } @@ -482,17 +502,21 @@ export class SessionManager { * Contract session.create; on success merge into summaries immediately (no * wait for the next refresh). A created session is blank by definition * (entity birth precedes the first message). - * @param opts - target workspace or working directory, plus an optional caller-owned id. - * @returns the create result. + * @param opts - target workspace or working directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the create result, naming the Session the Host created or reused. */ async create( opts: { workspaceId?: WorkspaceId cwd?: string sessionId?: SessionId + agentPreset?: string } = {}, ): Promise> { - const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } + const shared = { + ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), + ...(opts.agentPreset === undefined ? {} : { agentPreset: opts.agentPreset }), + } const payload = opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId, ...shared } : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } @@ -526,16 +550,17 @@ export class SessionManager { * preserve it or lower it after an exact cut before the first `turn/start`; * lineage rides parentSessionId. A child published before Workspace * attachment fails is also reconciled into the list. - * @param opts - source session and the optional exact inclusive boundary seq. + * @param opts - source session, optional exact inclusive boundary seq, and permission to start migration. * @returns the fork result (the child session id). */ async fork( - opts: { sessionId: SessionId; atSeq?: SessionSeq }, + opts: { sessionId: SessionId; atSeq?: SessionSeq; allowMigration?: boolean }, ): Promise> { const source = this.summaries.find(s => s.sessionId === opts.sessionId) const result = await this.remote.session.fork({ sessionId: opts.sessionId, ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, + ...opts.allowMigration === undefined ? {} : { allowMigration: opts.allowMigration }, }) const childId = result.ok ? result.value.sessionId @@ -774,6 +799,7 @@ export class SessionManager { && prev.blank === entry.blank && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth + && prev.formatStatus === entry.formatStatus && prev.projectionValues === entry.projectionValues ) return prev this.entryCache.set(entry.sessionId, entry) @@ -813,6 +839,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi ...(mutation.kind === 'upsert' ? { agentAvailable: mutation.summary.agentAvailable, running: mutation.summary.running, + ...(mutation.summary.formatStatus === undefined ? {} : { formatStatus: mutation.summary.formatStatus }), } : {}), ...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}), ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined @@ -823,6 +850,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId && filled.origin === existing.origin && filled.blank === existing.blank && filled.agentAvailable === existing.agentAvailable && filled.running === existing.running + && filled.formatStatus === existing.formatStatus ) return [...summaries] return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) } diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index ae36cea19c..c10c81c475 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -6,8 +6,8 @@ * (`{ value, seq }`) comes from the connected Host — a follow opening * baseline, a Session Controller `projection` frame, a list block the Host * computed for an attached Session — and merges under **higher seq wins** - * against other sequenced rows. A `cached` row (`{ value }`) comes from the - * session list's view of the persisted checkpoint, carries no comparable + * against other sequenced rows. A `cached` row (`{ value }`) comes from + * a Host view of the persisted checkpoint, carries no comparable * seq, and yields to every sequenced write. No client-side domain folding * exists: a domain ships projection support with zero client code. Per-key * bare observable faces feed `useProjection` (ui-renderer binds them). @@ -166,7 +166,7 @@ export class ProjectionValueStore { } /** - * Fill keys from a session-list block the Host labeled `cached`: a zero-I/O + * Fill keys from a Host block labeled `cached`: a zero-I/O * view of the persisted checkpoint. A cached value lands only where no * sequenced row exists: a connected Session has already answered for such * a key, and the list's view of the persisted checkpoint cannot be newer diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 964105a486..b992a34920 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -21,6 +21,7 @@ import { SessionManager } from './manager.ts' import type { SessionRemotes } from './remotes.ts' import type { SessionListPhase, SessionSearchResultItem, SessionProjectionSnapshot } from './manager.ts' import type { Session } from './session.ts' +import type { SessionListEntry } from './lineage.ts' /** Session list row projected from the host list RPC plus live stream increments. */ export interface SessionSummary { @@ -33,6 +34,8 @@ export interface SessionSummary { parentId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' + /** Format classification supplied by the Host list, when available. */ + formatStatus?: SessionListEntry['formatStatus'] /** Host running state for `ids` members; a display fallback for other rows. */ running: boolean /** Local ownership counts; Host metadata refreshes cannot overwrite them. */ @@ -332,14 +335,14 @@ export class ClientSessions implements ISessions { * Resolve an already discovered direct-parent address without opening it. * Feature plugins use this to avoid Agent-bound RPCs in persisted child views. * @param id - possible addressed child id. - * @returns A retained or loaded-catalog address, without retaining a new selection or scope. + * @returns A retained, catalog-derived, or unknown-mode listed address, without retaining a new selection or scope. */ subagentAddress(id: SessionId): SubagentAddress | undefined { return this.manager.subagentAddress(id) } /** - * Load all Session projections once per connection; retry an unsuccessful initial read. + * Read projections without starting migration; retry failed or migration-deferred reads. * @param sessionId - Session to inspect without opening its conversation. */ refreshProjections(sessionId: SessionId): Promise { @@ -424,11 +427,16 @@ export class ClientSessions implements ISessions { /** * Create a Host Session and publish its catalog row before resolving. * Callers retain the returned identity before borrowing its binding. - * @param opts - target workspace or directory and an optional preallocated id. - * @returns the new session id. + * @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the created or reused session id. * @throws {SessionCreateError} with the requested id. */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { + async create(opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + agentPreset?: string + } = {}): Promise { const result = await this.manager.create(opts) if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) this.projectList() @@ -443,7 +451,7 @@ export class ClientSessions implements ISessions { * seq (a real event seq the caller already knows; a cut inside an open * turn is balanced Host-side with synthetic closers, and omission selects * the latest completed-turn prefix), and whether to increment an - * inherited durable title before resolving. + * inherited durable title before resolving, plus permission to start source migration. * @returns the child session id. * @throws {SessionForkError} with the source id. * @throws {Error} when a requested child-title rename fails after creation. @@ -453,6 +461,7 @@ export class ClientSessions implements ISessions { atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void + allowMigration?: boolean }): Promise { const sourceTitle = opts.increaseTitle ? this.list.getSnapshot().byId[opts.sessionId]?.title @@ -460,6 +469,7 @@ export class ClientSessions implements ISessions { const result = await this.manager.fork({ sessionId: opts.sessionId, ...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(opts.atSeq) }), + ...(opts.allowMigration === undefined ? {} : { allowMigration: opts.allowMigration }), }) if (!result.ok) throw new SessionForkError(result.error, opts.sessionId) this.projectList() @@ -622,6 +632,7 @@ export class ClientSessions implements ISessions { retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy, blank: entry.blank, updatedAt: entry.updatedAt, + formatStatus: entry.formatStatus, ...(entry.projectionValues === undefined ? {} : { projectionValues: entry.projectionValues }), diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 7e900b92ae..5bb0a7039d 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -33,6 +33,7 @@ import { apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner, inspectApiSession, + type ApiSessionCreated, } from './agent.ts' import type { SessionAttachmentRequest, @@ -98,15 +99,15 @@ export class SessionCommandController { ) {} /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named + * blank Session while it is an unarchived member of the requested Workspace. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ async create(request: SessionCreateRequest): Promise { if (request.workspaceId !== undefined && request.cwd !== undefined) { throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {}) } - const sessionId = request.sessionId ?? brandString(`session-${randomUUID()}`) let workspace: Workspace | undefined if (request.workspaceId !== undefined) { workspace = this.ctx.workspaceRegistry.get(request.workspaceId) @@ -117,17 +118,19 @@ export class SessionCommandController { } } const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd - let adopted: Agent + let created: ApiSessionCreated try { - adopted = await this.agents.ensureSession( - sessionId, + created = await this.agents.createSession({ + ...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }), cwd, - request.sessionId !== undefined, - request.agentPreset, - ) + ...(request.agentPreset === undefined ? {} : { agentPreset: request.agentPreset }), + reusable: sessionId => !this.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId) + && (workspace === undefined || workspace.sessionIds.includes(sessionId)), + }) } catch (error) { - this.rejectCreation(sessionId, error) + this.rejectCreation(error) } + const sessionId = created.agent.id if (workspace !== undefined) { try { await workspace.attachSession(sessionId) @@ -139,8 +142,7 @@ export class SessionCommandController { ) } } - const agentPreset = this.agents.presetForSession(adopted.session) - return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) } + return { sessionId, ...(created.agentPreset === undefined ? {} : { agentPreset: created.agentPreset }) } } /** @@ -530,16 +532,12 @@ export class SessionCommandController { return found.agent } - private rejectCreation(sessionId: SessionId, error: unknown): never { + private rejectCreation(error: unknown): never { if (remoteErrorOf(error) !== undefined) throw error - if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') { - throw new RemoteError('session/writer-held', error.message, { sessionId }) - } if (error instanceof ApiSessionPresetConflict) { throw new RemoteError('agent-preset/conflict', error.message, { sessionId: error.sessionId, requestedPreset: error.requestedPreset, - ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), }) } if (error instanceof ApiSessionCwdConflict) { @@ -552,7 +550,7 @@ export class SessionCommandController { if (error instanceof ApiSessionSubagentOwnership) { throw apiSessionSubagentOwnershipError(error.sessionId) } - throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {}) + throw new RemoteError('gateway/internal', `failed to create session: ${String(error)}`, {}) } private async readSessionState(sessionId: SessionId): Promise { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 8e38822fbc..b28be14073 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { @@ -272,9 +273,9 @@ export class SessionController extends TypertRemoteService { } /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise { @@ -415,11 +416,19 @@ export class SessionController extends TypertRemoteService { * Fork one cold-readable exact event prefix into a new Session. An omitted * boundary selects the latest completed-turn prefix; an open cut receives * synthetic fork closers. - * @param request - source Session and optional exact inclusive event boundary. + * @param request - source Session, optional exact inclusive event boundary, and migration preflight choice. * @returns the new Session identity. */ @Remote('fork') - fork(request: SessionForkRequest): Promise { + async fork(request: SessionForkRequest): Promise { + if (request.allowMigration === false && this.ctx.sessions.get(request.sessionId) === undefined) { + const stored = await this.ctx.get('sessionPersistence')?.stat(request.sessionId) + if (stored?.formatStatus === 'migration-required' && this.ctx.sessions.get(request.sessionId) === undefined) { + throw new RemoteError('session/migration-required', 'Open the source Session to complete migration before forking', { + sessionId: request.sessionId, + }) + } + } return this.commands.fork(request) } @@ -489,10 +498,10 @@ export class SessionController extends TypertRemoteService { } /** - * Read all registered projections without activating an Agent. + * Read exact projections, returning cached hints only when migration is required. * @param request - Session whose current values are required. - * @param signal - cancellation for the Session observation. - * @returns complete baseline, or null when the Session does not exist. + * @param signal - cancellation for the Session read. + * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ @Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { @@ -501,16 +510,29 @@ export class SessionController extends TypertRemoteService { throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {}) } try { + signal.throwIfAborted() + const live = this.liveProjections(sessionId) + if (live !== undefined) return live + const stored = await this.ctx.get('sessionPersistence')?.stat(sessionId, { signal }) + signal.throwIfAborted() + const attached = this.liveProjections(sessionId) + if (attached !== undefined) return attached + if (stored === undefined) return null + if (stored.formatStatus === 'migration-required') { + const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(stored.header) + return { kind: 'migration-required', values: { ...cached?.values } as SessionProjectionValues } + } using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal }) const projections = observation.projections if (projections === undefined) { throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {}) } - return { asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues } + return { kind: 'sequenced', asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues } } catch (error: unknown) { - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return null - if (signal.aborted - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { + if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + return this.liveProjections(sessionId) ?? null + } + if (signal.aborted || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error }) } if (error instanceof RemoteError) throw error @@ -518,6 +540,15 @@ export class SessionController extends TypertRemoteService { } } + private liveProjections( + sessionId: SessionId, + ): Extract | undefined { + const session = this.ctx.sessions.get(sessionId) + if (session === undefined) return undefined + const snapshot = this.ctx.sessionProjections.snapshot(session) + return { kind: 'sequenced', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } + } + /** * Stream a complete live-control baseline followed by replacement frames. * @param signal - cancellation owned by the Remote stream carrier. diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index c51d119805..d7ef1ad43c 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -8,7 +8,7 @@ import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' -import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' +import { SessionQueryError, type SessionRecord, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' import { @@ -116,6 +116,7 @@ export class ApiSessionList { sessionId: session.id, updatedAt: updatedAt(session.header, metadata), agentAvailable: this.ctx.agents.get(session.id)?.session === session, + formatStatus: 'current', running: this.ctx.agents.get(session.id)?.status === 'running', blank: metadata?.blank ?? session.seq === 0, ...listFields(session.header), @@ -133,7 +134,7 @@ export class ApiSessionList { const records = await this.ctx.sessionQuery.listSessions(signal) signal?.throwIfAborted() const items: SessionSummary[] = [] - const cold: SessionHeader[] = [] + const cold: SessionRecord[] = [] let yieldDeadline = performance.now() + this.workSliceMs for (const record of records) { signal?.throwIfAborted() @@ -141,7 +142,7 @@ export class ApiSessionList { if (live !== undefined) { items.push(this.summaryFor(live)) } else if (record.header.cwd !== undefined) { - cold.push(record.header) + cold.push(record) } if (performance.now() >= yieldDeadline) { await scheduler.yield() @@ -149,9 +150,9 @@ export class ApiSessionList { yieldDeadline = performance.now() + this.workSliceMs } } - for (const header of cold) { + for (const record of cold) { signal?.throwIfAborted() - items.push(this.summarizeCold(header)) + items.push(this.summarizeCold(record)) if (performance.now() >= yieldDeadline) { await scheduler.yield() signal?.throwIfAborted() @@ -163,13 +164,14 @@ export class ApiSessionList { return items } - private summarizeCold(header: SessionHeader): SessionSummary { + private summarizeCold({ header, formatStatus }: SessionRecord): SessionSummary { const projections = this.projectionsFor(header, undefined) const metadata = projections?.values.sessionListMetadata return { sessionId: header.id, updatedAt: updatedAt(header, metadata), agentAvailable: false, + ...(formatStatus === undefined ? {} : { formatStatus }), running: false, // A large, metadata-less, or inaccessible cache miss remains unknown and visible. blank: metadata?.blank ?? false, diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index a8f0d7e450..03878bfae7 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -177,6 +177,8 @@ export type QueueAction = export interface SessionSummary { /** Whether this Session currently owns a live Agent. */ readonly agentAvailable: boolean + /** Current for attached Sessions; otherwise the persistence format status when reported. */ + readonly formatStatus?: 'current' | 'migration-required' readonly sessionId: SessionId readonly updatedAt: number readonly running: boolean @@ -217,13 +219,13 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'agent-preset/conflict': { readonly sessionId: SessionId readonly requestedPreset: string - readonly existingPreset?: string } 'session/attachment-invalid': { readonly reason: string } 'session/queue-item-not-found': { readonly itemId: MessageId } 'session/steer-unavailable': { readonly itemId: MessageId } 'session/title-invalid': { readonly sessionId: SessionId } 'session/fork-unavailable': { readonly sessionId: SessionId } + 'session/migration-required': { readonly sessionId: SessionId } 'subagent/not-found': { readonly parentSessionId: SessionId readonly childSessionId: SessionId @@ -281,17 +283,23 @@ export interface SessionSearchValue { readonly hasMore: boolean } -/** Session creation or explicit-id adoption request. */ +/** Request for a Session for a new conversation. */ export interface SessionCreateRequest { + /** Workspace the Session joins; exclusive with `cwd`. */ readonly workspaceId?: WorkspaceId + /** Directory the Session owns when no Workspace is named. */ readonly cwd?: string + /** Blank Session to reuse while it is reusable, or the identity of a new Session when none is stored under it. */ readonly sessionId?: SessionId + /** Preset the Session runs, replacing a reused Session's recorded preset; the configured default when omitted. */ readonly agentPreset?: string } /** Session creation response value. */ export interface SessionCreateValue { + /** The created or reused Session, which may differ from the requested `sessionId`. */ readonly sessionId: SessionId + /** Preset the Session runs, when the deployment composes a preset roster. */ readonly agentPreset?: string } @@ -322,6 +330,8 @@ export interface SessionForkRequest { readonly sessionId: SessionId /** Exact inclusive source event seq; omission selects the latest completed-turn prefix. */ readonly atSeq?: number + /** False asks the Host to check for required migration before forking. Defaults to true. */ + readonly allowMigration?: boolean } /** Identity of a newly forked Session. */ @@ -419,8 +429,11 @@ export interface SessionProjectionsRequest { readonly sessionId: SessionId } -/** Complete Session projection baseline; null when the Session does not exist. */ -export type SessionProjectionsValue = SessionProjectionBaseline | null +/** Exact projections carry their event cut; required migration returns sequence-free cache hints. Null means absent. */ +export type SessionProjectionsValue = + | (SessionProjectionBaseline & { readonly kind: 'sequenced' }) + | { readonly kind: 'migration-required'; readonly values: SessionProjectionValues } + | null /** One raw Session event in the Remote journal. */ export interface SessionEventEntry { diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 4756ae45b0..40dceeefb9 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -6,7 +6,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -15,7 +15,6 @@ import { ApiSessionAgentController, ApiSessionCwdConflict, ApiSessionNotFound, - ApiSessionSubagentOwnership, inspectApiSession, } from '../src/agent.ts' import { installModelSelectionProjection } from '../src/model-selection-projection.ts' @@ -329,123 +328,8 @@ describe('ApiSession model selection', () => { }) }) -describe('ApiSession create or adoption', () => { - it('shares one in-flight creation between concurrent callers', async () => { - const { ctx, agents } = await harness() - const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-')) - tempDirs.push(cwd) - const meta = header('concurrent-create', cwd) - const created = unpublishedAgent(ctx, meta) - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - const create = vi.spyOn(ctx.agents, 'create').mockImplementation(async () => { - await gate - return { agent: created, dispose: () => Promise.resolve() } - }) - - const first = agents.ensureSession(meta.id, cwd, false) - const second = agents.ensureSession(meta.id, cwd, false) - release() - - await expect(Promise.all([first, second])).resolves.toEqual([created, created]) - expect(create).toHaveBeenCalledOnce() - }) - - it('accepts a raced ordinary creation and rejects a raced attached child', async () => { - const ordinary = await harness() - const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-')) - tempDirs.push(cwd) - const ordinaryMeta = header('create-race', cwd) - const winner = agent(ordinary.ctx, ordinaryMeta) - vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => { - await ordinary.ctx.agents.register(winner) - throw new Error('raced creation') - }) - await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false)) - .resolves.toBe(winner) - - const child = await harness() - const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-')) - tempDirs.push(childCwd) - const childId = SessionId('create-child-race') - vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => { - child.ctx.sessions.create(childId, { - meta: { cwd: childCwd, parentSession: SessionId('parent'), origin: 'subagent' }, - }) - throw new Error('raced child creation') - }) - await expect(child.agents.ensureSession(childId, childCwd, false)) - .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) - }) - - it('validates ownership and cwd on the Agent returned by creation', async () => { - const child = await harness() - const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-')) - tempDirs.push(childCwd) - const childMeta = { - ...header('returned-child', childCwd), - parentSession: SessionId('parent'), - origin: 'subagent' as const, - } - const childAgent = unpublishedAgent(child.ctx, childMeta) - vi.spyOn(child.ctx.agents, 'create').mockResolvedValue({ - agent: childAgent, - dispose: () => Promise.resolve(), - }) - await expect(child.agents.ensureSession(childMeta.id, childCwd, false)) - .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) - - const wrong = await harness() - const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-')) - tempDirs.push(requestedCwd) - const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other')) - vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({ - agent: wrongAgent, - dispose: () => Promise.resolve(), - }) - await expect(wrong.agents.ensureSession(wrongAgent.id, requestedCwd, false)) - .rejects.toBeInstanceOf(ApiSessionCwdConflict) - }) - - it('resumes a matching persisted identity and preserves its selected preset', async () => { - const { ctx, agents } = await harness() - const meta = { ...header('stored'), agentPreset: 'minimal' } - const events = [{ - type: 'agent-preset/selected', - seq: 0, - time: 1, - data: { agentPreset: 'minimal' }, - }] as SessionEvent[] - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events }), - }) - ctx.provide('agentPresets', { - resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }), - mount: () => Promise.resolve(), - } as never) - const resumed = { - id: meta.id, - session: { - id: meta.id, - header: meta, - snapshotEvents: () => events, - eventAt: (seq: number) => events[seq], - seq: events.length, - }, - status: 'idle', - ctx, - } as unknown as Agent - const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({ - agent: resumed, - dispose: () => Promise.resolve(), - }) - - await expect(agents.ensureSession(meta.id, '/workspace', true, 'minimal')).resolves.toBe(resumed) - expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id })) - }) - - it('rejects an ownership race before resume and a persisted cwd conflict', async () => { +describe('ApiSession creation', () => { + it('rejects a resume that subagent routing adopts before publication', async () => { const child = await harness() const childMeta = header('resume-child-race') providePersistence(child.ctx, { @@ -464,15 +348,6 @@ describe('ApiSession create or adoption', () => { await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ error: { code: 'session/agent-busy' }, }) - - const conflict = await harness() - const stored = header('stored-cwd-conflict', '/stored') - providePersistence(conflict.ctx, { - list: () => Promise.resolve([stored]), - inspect: () => Promise.resolve({ meta: stored, events: [] }), - }) - await expect(conflict.agents.ensureSession(stored.id, '/requested', true)) - .rejects.toBeInstanceOf(ApiSessionCwdConflict) }) it('surfaces directory creation failure', async () => { @@ -481,7 +356,7 @@ describe('ApiSession create or adoption', () => { tempDirs.push(parent) const file = join(parent, 'file') writeFileSync(file, 'not a directory') - await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) + await expect(agents.createSession({ cwd: join(file, 'child'), reusable: () => true })) .rejects.toThrow('failed to ensure project directory') }) }) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index 87bdc5a399..f43ce3f116 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -5,11 +5,14 @@ import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace' import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController, ApiSessionCwdConflict, + type ApiSessionCreated, + type ApiSessionCreateRequest, } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' @@ -18,18 +21,31 @@ async function expectFailure(operation: Promise, code: string): Promise await expect(operation).rejects.toMatchObject({ code }) } -function controllerAgents(overrides: object = {}): ApiSessionAgentController { - return { - ensureSession: () => Promise.resolve(), - composeAgent: () => Promise.resolve({ setup: () => {} }), - presetForSession: () => undefined, - presetForObservation: () => undefined, - ...overrides, - } as unknown as ApiSessionAgentController +/** The Agent controller with a fixed Agent composition, and optionally a fixed creation outcome. */ +function controllerAgents( + ctx: Context, + fixed: { + readonly composeAgent?: ApiSessionAgentController['composeAgent'] + readonly createSession?: ApiSessionAgentController['createSession'] + } = {}, +): ApiSessionAgentController { + const agents = new ApiSessionAgentController(ctx) + vi.spyOn(agents, 'composeAgent').mockImplementation(fixed.composeAgent ?? (() => Promise.resolve({ setup: () => {} }))) + if (fixed.createSession !== undefined) vi.spyOn(agents, 'createSession').mockImplementation(fixed.createSession) + return agents +} + +/** A creation outcome that publishes `sessionId` in `cwd` as a stand-in Agent. */ +function publishedSession(ctx: Context, sessionId: SessionId) { + return (request: ApiSessionCreateRequest): Promise => { + const session = ctx.sessions.create(sessionId, { meta: { cwd: request.cwd } }) + return Promise.resolve({ agent: { id: session.id, session, status: 'idle', ctx } as Agent, agentPreset: undefined }) + } } async function baseContext(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) @@ -41,29 +57,19 @@ async function baseContext(): Promise { } describe('Session creation failures', () => { - it('mints an identity with the default cwd when no explicit target is supplied', async () => { + it('creates in the default cwd without a named identity when no explicit target is supplied', async () => { const ctx = await baseContext() - ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) - const ensureSession = vi.fn((sessionId: SessionId, cwd: string) => { - const session = ctx.sessions.create(sessionId, { meta: { cwd } }) - return Promise.resolve({ id: sessionId, session } as Agent) - }) - const controller = new SessionCommandController( - ctx, - controllerAgents({ ensureSession }), - '/default-workspace', - ) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [], archivedSessionIds: [] } as never) + const createSession = vi.fn(publishedSession(ctx, SessionId('session-minted'))) + const controller = new SessionCommandController(ctx, controllerAgents(ctx, { createSession }), '/default-workspace') const created = await controller.create({}) - expect(created.sessionId).toMatch(/^session-/) - expect(created).not.toHaveProperty('agentPreset') - expect(ensureSession).toHaveBeenCalledWith( - created.sessionId, - '/default-workspace', - false, - undefined, - ) + expect(created).toEqual({ sessionId: 'session-minted' }) + expect(createSession).toHaveBeenCalledOnce() + const request = createSession.mock.calls[0]?.[0] + expect(request?.cwd).toBe('/default-workspace') + expect(Object.keys(request ?? {}).sort()).toEqual(['cwd', 'reusable']) await ctx.fiber.dispose() }) @@ -72,7 +78,7 @@ describe('Session creation failures', () => { missing.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) const missingController = new SessionCommandController( missing, - controllerAgents(), + controllerAgents(missing), '/default', ) await expectFailure(missingController.create({ @@ -92,7 +98,7 @@ describe('Session creation failures', () => { } as never) const failedController = new SessionCommandController( failed, - controllerAgents(), + controllerAgents(failed, { createSession: publishedSession(failed, SessionId('workspace-session')) }), '/default', ) await expectFailure(failedController.create({ @@ -119,10 +125,6 @@ describe('Session creation failures', () => { error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'), code: 'session/conflict', }, - { - error: Object.assign(new Error('writer already held'), { name: 'SessionAlreadyOwnedError' }), - code: 'session/writer-held', - }, { error: new Error('factory unavailable'), code: 'gateway/internal', @@ -132,7 +134,7 @@ describe('Session creation failures', () => { ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) const controller = new SessionCommandController( ctx, - controllerAgents({ ensureSession: () => Promise.reject(error) }), + controllerAgents(ctx, { createSession: () => Promise.reject(error) }), '/default', ) @@ -144,7 +146,7 @@ describe('Session creation failures', () => { it('rejects contradictory create targets', async () => { const ctx = await baseContext() - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.create({ workspaceId: 'workspace-1' as WorkspaceId, @@ -184,7 +186,7 @@ describe('Session fork failures', () => { const withoutPersistence = await baseContext() withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never) const unavailableController = new SessionCommandController( - withoutPersistence, controllerAgents(), '/default', + withoutPersistence, controllerAgents(withoutPersistence), '/default', ) await expectFailure(unavailableController.fork({ sessionId: SessionId('missing'), @@ -197,7 +199,7 @@ describe('Session fork failures', () => { list: () => Promise.resolve([]), inspect: vi.fn(), }) as never) - const missingController = new SessionCommandController(missing, controllerAgents(), '/default') + const missingController = new SessionCommandController(missing, controllerAgents(missing), '/default') await expectFailure(missingController.fork({ sessionId: SessionId('missing'), }), 'session/not-found') @@ -208,7 +210,7 @@ describe('Session fork failures', () => { const ctx = await baseContext() ctx.provide('workspaceRegistry', { list: () => [] } as never) vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal') await ctx.fiber.dispose() @@ -218,7 +220,7 @@ describe('Session fork failures', () => { const ctx = await baseContext() ctx.provide('workspaceRegistry', { list: () => [] } as never) const source = ctx.sessions.create(SessionId('empty-source')) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable') await expect(controller.fork({ sessionId: source.id, atSeq: 0 })).rejects.toMatchObject({ @@ -237,7 +239,7 @@ describe('Session fork failures', () => { parentSession: SessionId('parent'), origin: 'subagent', }) - const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default') + const lineageController = new SessionCommandController(lineage, controllerAgents(lineage), '/default') await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal') await lineage.fiber.dispose() @@ -245,7 +247,7 @@ describe('Session fork failures', () => { creation.provide('workspaceRegistry', { list: () => [] } as never) const source = completedSession(creation, 'creation-source', '/workspace') vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed')) - const creationController = new SessionCommandController(creation, controllerAgents(), '/default') + const creationController = new SessionCommandController(creation, controllerAgents(creation), '/default') await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal') await creation.fiber.dispose() }) @@ -262,7 +264,7 @@ describe('Session fork failures', () => { const create = vi.spyOn(ctx.agents, 'create').mockImplementation( (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), ) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed') const options = create.mock.calls[0]?.[0] @@ -279,7 +281,7 @@ describe('Session fork failures', () => { const create = vi.spyOn(ctx.agents, 'create').mockImplementation( (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), ) - const controller = new SessionCommandController(ctx, controllerAgents({ + const controller = new SessionCommandController(ctx, controllerAgents(ctx, { composeAgent: () => Promise.resolve({ agentPreset: 'minimal', setup: () => {} }), }), '/default') diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 4da138a716..bcf4a033be 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -4,6 +4,7 @@ */ import { describe, expect, onTestFinished, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { SessionSeq } from '@deepseek-ai/dsh-session/types' @@ -14,6 +15,7 @@ import { } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' +import { ClientSessions } from '../src/client/sessions/service.ts' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import { entries, plainTurn } from './event-script.client.ts' import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts' @@ -647,6 +649,7 @@ describe('list lifecycle', () => { const items = manager.getListSnapshot().items // Cold row: title surfaces straight from the list block — no open, no history. expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached') + expect(manager.getListSnapshot().projectionsBySession[S1]?.state).toBe('idle') // A cached list block never displaces a sequenced value, whatever watermark its record holds. expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') }) @@ -741,12 +744,83 @@ describe('subagent catalogs', () => { expect(manager.getListSnapshot().items).toEqual([]) }) + it('prefers retained and catalog addresses to header-derived unknown modes', ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S2, { parentSessionId: S1, origin: 'subagent' })) + manager.handleControlFrame({ + type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, + value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }], + }) + + expect(manager.subagentAddress(S2)).toEqual({ + parentSessionId: S1, childSessionId: S2, mode: 'continuable', + }) + const retained: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'one-shot' } + manager.resolveTarget(retained) + expect(manager.subagentAddress(S2)).toBe(retained) + expect(remote.session.list).not.toHaveBeenCalled() + expect(remote.session.projections).not.toHaveBeenCalled() + }) + + it.for([ + ['ordinary session', {}], + ['ordinary fork', { parentSessionId: S1 }], + ['parentless subagent', { origin: 'subagent' }], + ] as const)('does not derive a subagent address for an %s', ([, fields], { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S2, fields)) + + expect(manager.subagentAddress(S2)).toBeUndefined() + expect(manager.subagentAddress(S1)).toBeUndefined() + expect(manager.resolveTarget(S2)).toBe(S2) + expect(manager.get(S2).getSnapshot().subagent).toBeNull() + expect(remote.session.list).not.toHaveBeenCalled() + expect(remote.session.projections).not.toHaveBeenCalled() + }) + + it('retains a header-discovered ancestor through subagent follow without discovery reads', async ({ mock, remote, start }) => { + const grandchild = 'header-grandchild' as SessionId + const descendantAddress: SubagentAddress = { + parentSessionId: S2, childSessionId: grandchild, mode: 'continuable', + } + const ancestorAddress: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'unknown' } + mock.load(sessionWorld) + remote.session.list.mockResolvedValueOnce(ok({ items: [ + summary(S1), + summary(S2, { parentSessionId: S1, origin: 'subagent' }), + summary(grandchild, { parentSessionId: S2, origin: 'subagent' }), + ] })) + const client = await start() + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const sessions = new ClientSessions(ctx, client.ctx.remote) + await sessions.refresh() + using descendant = sessions.retain(descendantAddress, { source: 'mainView' }) + await descendant.ready + + using ancestor = sessions.retain(S2, { source: 'mainView' }) + await ancestor.ready + + expect(mock.log.requests(FOLLOW)).toMatchObject([ + { address: { kind: 'subagent', ...descendantAddress } }, + { address: { kind: 'subagent', ...ancestorAddress } }, + ]) + expect(ancestor.binding.session.getSnapshot()).toMatchObject({ + openState: 'open', subagent: { address: ancestorAddress }, + }) + expect(remote.session.list).toHaveBeenCalledOnce() + expect(remote.session.projections).not.toHaveBeenCalled() + expect(mock.log.requests('session/page')).toEqual([]) + }) + it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => { remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [ summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent' }), ] as never[] }))) - remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, + remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker', }] } }))) mock.load(sessionWorld) @@ -859,6 +933,74 @@ describe('subagent catalogs', () => { expect(remote.session.projections.mock.calls.map(([request]) => request)).toEqual([]) }) + it.for(['control', 'follow'] as const)('replaces cached projection hints with the %s baseline', async (source, { mock, remote, start }) => { + mock.load(sessionWorld) + remote.session.projections.mockResolvedValueOnce(ok({ kind: 'migration-required', values: { + title: 'Cached title', + subagentCatalog: [{ id: S2, createdAt: 1, mode: 'unknown' }], + } })) + const client = await start() + const manager = new SessionManager(client.ctx.remote) + onTestFinished(() => manager.dispose()) + + await manager.refreshProjections(S1) + expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ + state: 'migration-required', error: null, + values: { title: 'Cached title', subagentCatalog: [{ id: S2, mode: 'unknown' }] }, + }) + expect(remote.session.follow).not.toHaveBeenCalled() + const session = manager.get(S1) + expect(session.projections.seqOf('subagentCatalog')).toBeUndefined() + const baseline = { asOfSeq: 0, values: { + subagentCatalog: [{ id: S2, createdAt: 1, mode: 'one-shot' as const, label: 'Loaded child' }], + } } + if (source === 'control') { + manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } }) + } else { + mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline }))) + await session.open() + } + + expect(manager.getListSnapshot().projectionsBySession[S1]?.values).toEqual(baseline.values) + expect(session.projections.seqOf('subagentCatalog')).toBe(0) + expect(session.projections.get('title')).toBeUndefined() + }) + + it.for(['control', 'follow'] as const)('keeps %s projections when a cached read arrives late', async (source, { mock, remote, start }) => { + mock.load(sessionWorld) + const response = Promise.withResolvers>>() + remote.session.projections.mockReturnValueOnce(response.promise) + const client = await start() + const manager = new SessionManager(client.ctx.remote) + const read = manager.refreshProjections(S1) + const baseline = { asOfSeq: 4, values: { + title: 'Loaded title', + subagentCatalog: [{ id: S2, createdAt: 1, mode: 'continuable' as const, label: 'Loaded child' }], + } } + try { + expect(remote.session.projections).toHaveBeenCalledOnce() + if (source === 'control') { + manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } }) + } else { + mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline }))) + await manager.get(S1).open() + } + response.resolve(ok({ kind: 'migration-required', values: { + title: 'Cached title', subagentCatalog: [{ id: S2, createdAt: 1, mode: 'unknown' }], + } })) + await read + + expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ + state: 'migration-required', error: null, values: baseline.values, + }) + expect(manager.get(S1).projections.seqOf('subagentCatalog')).toBe(4) + } finally { + response.resolve(ok(null)) + await read + await manager.dispose() + } + }) + it('keeps newer pushed membership when the initial read returns an older baseline', async ({ mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) @@ -868,12 +1010,27 @@ describe('subagent catalogs', () => { type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 5, value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'pushed' }], }) - response.resolve(ok({ asOfSeq: 3, values: { subagentCatalog: [] } })) + response.resolve(ok({ kind: 'sequenced', asOfSeq: 3, values: { subagentCatalog: [] } })) await read expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog?.[0]?.label).toBe('pushed') expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) }) + it('retries a migration-deferred projection read and accepts the completed catalog', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + remote.session.projections.mockResolvedValueOnce(ok({ kind: 'migration-required', values: {} })) + await manager.refreshProjections(S1) + expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ + state: 'migration-required', values: {}, + }) + remote.session.projections.mockResolvedValueOnce(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) + await manager.refreshProjections(S1) + expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ + state: 'ready', values: { subagentCatalog: [] }, + }) + expect(remote.session.projections).toHaveBeenCalledTimes(2) + }) + it.for([false, true])('keeps live parent availability when an earlier initial read settles (failure: %s)', async (failure, { mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) @@ -885,7 +1042,7 @@ describe('subagent catalogs', () => { manager.handleSessionStatus(S1, false) response.resolve(failure ? err(new RemoteError('gateway/internal', 'offline', {})) - : ok({ asOfSeq: 0, values: { subagentCatalog: [] } })) + : ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) await read expect(child.getSnapshot().subagent?.parentAvailable).toBe(true) expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) @@ -925,7 +1082,7 @@ describe('subagent catalogs', () => { const read = manager.refreshProjections(S1) expect(manager.refreshProjections(S1)).toBe(read) expect(manager.getListSnapshot().projectionsBySession[S1]?.state).toBe('loading') - response.resolve(ok({ asOfSeq: 0, values: { + response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: S2, createdAt: 1, mode: 'one-shot' }], title: 'cold parent', } })) @@ -946,7 +1103,7 @@ describe('subagent catalogs', () => { const read = manager.refreshProjections(S1) manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1 })) manager.handleSessionStatus(S2, true) - response.resolve(ok({ asOfSeq: 0, values: { + response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }], } })) await read @@ -980,7 +1137,7 @@ describe('subagent catalogs', () => { value: [{ id: S2, createdAt: 1, mode: 'one-shot' }] }) await manager.refreshProjections(S1) expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'error', values: { subagentCatalog: [{ id: S2 }] } }) - remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 2, values: { subagentCatalog: [] } }))) + remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 2, values: { subagentCatalog: [] } }))) await manager.refreshProjections(S1) expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'ready', values: { subagentCatalog: [{ id: S2 }] } }) }) @@ -991,7 +1148,7 @@ describe('subagent catalogs', () => { const manager = makeManager(mock, remote) const read = manager.refreshProjections(S1) manager.handleSessionRemoved(S1) - response.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } })) + response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) await read expect(manager.getListSnapshot().items.some(item => item.sessionId === S1)).toBe(false) expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) @@ -1037,6 +1194,17 @@ describe('remaining branches', () => { expect(await manager.create()).toMatchObject({ ok: false }) }) + it('create forwards the requested Agent preset and catalogs the identity the Host returns', async ({ mock, remote }) => { + remote.session.create.mockResolvedValue(ok({ sessionId: S2, agentPreset: 'minimal' })) + const manager = makeManager(mock, remote) + + const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1, agentPreset: 'minimal' }) + + expect(remote.session.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: 'w1', sessionId: S1, agentPreset: 'minimal' }) + expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) + expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2]) + }) + it.for(['create', 'fork'] as const)('preserves pushed running state when a %s response arrives later', async (operation, { mock, remote }) => { const response = Promise.withResolvers>>() remote.session[operation].mockImplementation(() => response.promise) @@ -1081,6 +1249,20 @@ describe('remaining branches', () => { expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') }) + it.for([undefined, false, true])('forwards fork migration permission %s to the Remote request', async (allowMigration, { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + remote.session.fork.mockResolvedValueOnce(ok({ sessionId: S2 })) + const permission = allowMigration === undefined ? {} : { allowMigration } + + await expect(manager.fork({ sessionId: S1, atSeq: SessionSeq(7), ...permission })) + .resolves.toEqual(ok({ sessionId: S2 })) + + expect(remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: S1, atSeq: 7, ...permission }) + expect(remote.session.follow).not.toHaveBeenCalled() + expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S2, parentSessionId: S1 }) + }) + it('reconciles a fork child published before workspace attachment fails', async ({ mock, remote }) => { remote.session.fork.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', { sessionId: S2, workspaceId: 'w1', @@ -1317,7 +1499,7 @@ describe('connected generation', () => { summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent', running: hostRunning }), ] as never[] }))) - remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } }))) + remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } }))) const manager = makeManager(mock, remote) try { await manager.refreshList() @@ -1341,13 +1523,13 @@ describe('connected generation', () => { const previous = Promise.withResolvers>>() remote.session.projections .mockReturnValueOnce(previous.promise) - .mockResolvedValue(ok({ asOfSeq: 0, values: { subagentCatalog: [] } })) + .mockResolvedValue(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) const manager = makeManager(mock, remote) try { const read = manager.refreshProjections(S1) manager.handleConnected() expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(2) - previous.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } })) + previous.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } })) await read await vi.waitFor(() => { expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ @@ -1395,7 +1577,7 @@ describe('connected generation', () => { manager.handleConnected() expect(manager.get(S2).getSnapshot().subagent).toEqual({ address }) - parent.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, + parent.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker', }] } })) diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts new file mode 100644 index 0000000000..0a14c90f0a --- /dev/null +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -0,0 +1,424 @@ +/** Projection reads distinguish ordinary cold history from history that still needs migration. */ + +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionProjectionCache, { + checkpointRecord, + projectionCacheDomainSpec, + type CheckpointRecord, +} from '@deepseek-ai/dsh-session-projection-cache' +import { titleProjectionDefinition } from '@deepseek-ai/dsh-session-title' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import { subagentCatalogProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/catalog.ts' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { createSessionTestController } from './test-remote.ts' + +const contexts: Context[] = [] +const roots: string[] = [] +const readAudits: Array<() => void> = [] + +afterEach(async () => { + try { + for (const audit of readAudits.splice(0)) audit() + } finally { + vi.restoreAllMocks() + try { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + } finally { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) + } + } +}) + +async function harness(withPersistence = true) { + const root = await mkdtemp(join(tmpdir(), 'dsh-projection-discovery-')) + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + ctx.sessionProjections.register(titleProjectionDefinition) + if (withPersistence) { + await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'logs'), compression: 'none' }) + const list = vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(new Error('unexpected corpus listing')) + readAudits.push(() => { + expect(list).not.toHaveBeenCalled() + }) + } + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'test', model: 'test' }), + cwd: root, + }) + const list = vi.spyOn(ctx.sessionQuery, 'listSessions').mockRejectedValue(new Error('unexpected Session listing')) + readAudits.push(() => { + expect(list).not.toHaveBeenCalled() + }) + return { ctx, root, controller } +} + +type Harness = Awaited> + +async function stored( + fixture: Harness, + id: SessionId, + fields: Partial> = {}, + version = SESSION_FORMAT_VERSION, +) { + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id, + createdAt: 10, + delegationDepth: 0, + isSeeded: false, + ...fields, + } + const path = join(fixture.root, 'logs', '_no-cwd', id, `session.v${version}.jsonl`) + const bytes = `${JSON.stringify({ type: 'session', ...header, version })}\n` + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, bytes) + return { header, path, bytes } +} + +async function seedCache( + fixture: Harness, + header: SessionHeader, + catalog: 'child' | 'empty' | 'absent', + matchingIdentity = true, +) { + const rows: CheckpointRecord['rows'] = { + title: { ver: titleProjectionDefinition.stateVersion, seq: SessionSeq(50), val: 'cached title' }, + } + if (catalog !== 'absent') { + const empty = subagentCatalogProjectionDefinition.init(header, SessionLogOffset(0)) + const state = catalog === 'empty' ? empty : subagentCatalogProjectionDefinition.apply(empty, { + type: 'subagent/catalog', + seq: SessionSeq(0), + time: 1, + data: { + version: 1, + childId: SessionId('cached-child'), + childCreatedAt: 2, + mode: 'one-shot', + label: 'cached child', + }, + }) + const record = checkpointRecord.parse({ + identity: { createdAt: header.createdAt }, + rows: { subagentCatalog: { ver: subagentCatalogProjectionDefinition.stateVersion, seq: 50, val: state } }, + }) + Object.assign(rows, record.rows) + } + const record: CheckpointRecord = { + identity: { + formatVersion: SESSION_FORMAT_VERSION, + createdAt: matchingIdentity ? header.createdAt : header.createdAt + 1, + cwd: header.cwd, + isSeeded: header.isSeeded, + inheritedEventCount: SessionLogOffset(0), + }, + rows, + } + const cacheRoot = join(fixture.root, 'cache') + const path = join(cacheRoot, projectionCacheDomainSpec.name, 'sessions', `${header.id}.json`) + const bytes = JSON.stringify({ version: projectionCacheDomainSpec.version, record }) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, bytes) + await fixture.ctx.plugin(Storage) + await fixture.ctx.plugin(StorageJson, { root: cacheRoot }) + await fixture.ctx.plugin(StorageDomain, { backend: 'json' }) + await fixture.ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + const write = vi.spyOn(fixture.ctx.sessionProjectionCache, 'write').mockRejectedValue(new Error('unexpected cache write')) + const hydrate = vi.spyOn(fixture.ctx.sessionProjectionCache, 'hydratePrepared').mockImplementation(() => { + throw new Error('unexpected cached hydration') + }) + readAudits.push(() => { + expect(write).not.toHaveBeenCalled() + expect(hydrate).not.toHaveBeenCalled() + }) + return { path, bytes } +} + +describe('Session projection discovery', () => { + it('defers V3 migration without reading child bodies or publishing a successor', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('parent'), {}, 3) + const child = await stored(fixture, SessionId('child'), { origin: 'subagent', parentSession: parent.header.id, createdAt: 20 }, 3) + const grandchild = await stored(fixture, SessionId('grandchild'), { origin: 'subagent', parentSession: child.header.id, createdAt: 30 }, 3) + const fork = await stored(fixture, SessionId('ordinary-fork'), { parentSession: parent.header.id, isSeeded: true }, 3) + const unrelated = await stored(fixture, SessionId('unrelated'), { origin: 'subagent', parentSession: SessionId('another-parent') }, 3) + const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open') + const observe = vi.spyOn(fixture.ctx.sessionQuery, 'observeSession') + + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({ + kind: 'migration-required', + values: {}, + }) + expect(open).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({ + formatStatus: 'migration-required', + }) + expect(fixture.ctx.sessions.list()).toEqual([]) + for (const source of [parent, child, grandchild, fork, unrelated]) { + expect(await readFile(source.path, 'utf8')).toBe(source.bytes) + expect(await readdir(dirname(source.path))).toEqual(['session.v3.jsonl']) + } + }) + + it.each(['child', 'empty'] as const)('serves a valid %s catalog cache when migration is required', async (catalog) => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('cached-parent'), {}, 3) + const cache = await seedCache(fixture, parent.header, catalog) + const list = vi.spyOn(fixture.ctx.sessionQuery, 'listSessions').mockRejectedValue(new Error('unexpected fallback')) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({ + kind: 'migration-required', + values: { + title: 'cached title', + subagentCatalog: catalog === 'empty' ? [] : [{ id: 'cached-child', createdAt: 2, mode: 'one-shot', label: 'cached child' }], + }, + }) + expect(list).not.toHaveBeenCalled() + expect(await readFile(cache.path, 'utf8')).toBe(cache.bytes) + }) + + it('serves other cached hints without inventing a missing catalog', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('title-parent'), {}, 3) + await stored(fixture, SessionId('title-child'), { origin: 'subagent', parentSession: parent.header.id }) + const cache = await seedCache(fixture, parent.header, 'absent') + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({ + kind: 'migration-required', + values: { title: 'cached title' }, + }) + expect(fixture.ctx.sessionProjectionCache.cachedSnapshot(parent.header)?.values).toEqual({ title: 'cached title' }) + expect(await readFile(cache.path, 'utf8')).toBe(cache.bytes) + }) + + it('returns no hints from an unrelated cache identity', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('recreated-parent'), {}, 3) + await seedCache(fixture, parent.header, 'child', false) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({ + kind: 'migration-required', values: {}, + }) + }) + + it.each([true, false])('returns null for an absent Session (persistence mounted: %s)', async (withPersistence) => { + const fixture = await harness(withPersistence) + const list = vi.spyOn(fixture.ctx.sessionQuery, 'listSessions') + await expect(fixture.controller.projections({ sessionId: SessionId('missing') }, new AbortController().signal)).resolves.toBeNull() + expect(list).not.toHaveBeenCalled() + }) + + it('reads a current-format cold catalog from disk when its cache is absent', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('current-cold')) + const event = { + type: 'subagent/catalog', seq: 0, time: 1, + data: { version: 1, childId: 'current-child', childCreatedAt: 2, mode: 'continuable', label: 'worker' }, + } + await writeFile(parent.path, `${parent.bytes}${JSON.stringify(event)}\n`) + const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open') + const observe = vi.spyOn(fixture.ctx.sessionQuery, 'observeSession') + const signal = new AbortController().signal + + await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal)).resolves.toMatchObject({ + kind: 'sequenced', asOfSeq: 0, + values: { subagentCatalog: [{ id: 'current-child', mode: 'continuable', label: 'worker' }] }, + }) + expect(observe).toHaveBeenCalledWith(parent.header.id, { signal }) + expect(open).toHaveBeenCalledWith(parent.header.id, 'read', { signal }) + expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined() + expect(fixture.ctx.agents.get(parent.header.id)).toBeUndefined() + }) + + it('reports read-prepared historical files as migration-required until a successor is published', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('prepared-cold'), {}, 3) + const observation = await fixture.ctx.sessionQuery.observeSession(parent.header.id) + observation[Symbol.dispose]() + const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open').mockRejectedValue(new Error('unexpected body reread')) + + expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({ + formatStatus: 'migration-required', + }) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)) + .resolves.toEqual({ kind: 'migration-required', values: {} }) + expect(open).not.toHaveBeenCalled() + expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined() + expect(await readFile(parent.path, 'utf8')).toBe(parent.bytes) + expect(await readdir(dirname(parent.path))).toEqual(['session.v3.jsonl']) + }) + + it('reads sequenced projections after write publication replaces the disk format status', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('published-cold'), {}, 3) + using observation = await fixture.ctx.sessionQuery.observeSession(parent.header.id) + const expected = observation.projections + const signal = new AbortController().signal + await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal)) + .resolves.toEqual({ kind: 'migration-required', values: {} }) + + const writer = await fixture.ctx.sessionPersistence.open(parent.header.id, 'write') + try { + await writer.flush() + } finally { + await writer.close() + } + + expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({ + formatStatus: 'current', + }) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal)) + .resolves.toEqual({ kind: 'sequenced', ...expected }) + expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined() + expect(await readFile(parent.path, 'utf8')).toBe(parent.bytes) + // POSIX retains the write-lock inode after close; Windows uses a semaphore. + expect((await readdir(dirname(parent.path))).filter(name => name !== 'session.lock').sort()) + .toEqual(['session.v3.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`].sort()) + }) + + it('reports unsupported current-format events instead of deferring migration', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('corrupt-current')) + await writeFile(parent.path, `${parent.bytes}${JSON.stringify({ type: 'missing/plugin-event', seq: 0, time: 1, data: {} })}\n`) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/internal' }) + }) + + it('reports unavailable projections from an otherwise readable observation', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('without-projections')) + using source = await fixture.ctx.sessionQuery.observeSession(parent.header.id) + const { projections: _projections, ...observation } = source + vi.spyOn(fixture.ctx.sessionQuery, 'observeSession').mockResolvedValueOnce(observation) + + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'session/projections-unavailable' }) + }) + + it.each([false, true])('rechecks live history when the stored source disappears after preflight (attached: %s)', async (attached) => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('missing-after-preflight')) + const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) + vi.spyOn(fixture.ctx.sessionPersistence, 'stat') + .mockResolvedValueOnce(snapshot) + .mockImplementationOnce(() => { + if (attached) fixture.ctx.sessions.create(parent.header.id, { meta: parent.header }) + return Promise.resolve(undefined) + }) + + const result = await fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal) + expect(result).toEqual(attached + ? { kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(fixture.ctx.sessions.get(parent.header.id)!) } + : null) + }) + + it('serves the live registry cut instead of a conflicting cached hint', async () => { + const fixture = await harness() + const live = fixture.ctx.sessions.create(SessionId('live'), { meta: { cwd: fixture.root } }) + live.append('turn/start', { turn: 1 }) + const expected = fixture.ctx.sessionProjections.snapshot(live) + await seedCache(fixture, live.header, 'child') + const cached = vi.spyOn(fixture.ctx.sessionProjectionCache, 'cachedSnapshot') + const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValue(new Error('metadata unavailable')) + await expect(fixture.controller.projections({ sessionId: live.id }, new AbortController().signal)).resolves.toEqual({ + kind: 'sequenced', ...expected, + }) + expect(stat).not.toHaveBeenCalled() + expect(cached).not.toHaveBeenCalled() + }) + + it.each([true, false])('prefers a Session attached during stat (stored header present: %s)', async (present) => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('stat-race'), {}, 3) + const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockImplementation(() => { + entered.resolve(undefined) + return finish.promise + }) + const operation = fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal) + try { + await entered.promise + const live = fixture.ctx.sessions.create(parent.header.id, { meta: parent.header }) + live.append('turn/start', { turn: 1 }) + finish.resolve(present ? snapshot : undefined) + await expect(operation).resolves.toEqual({ kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(live) }) + } finally { + finish.resolve(undefined) + await Promise.allSettled([operation]) + } + }) + + it('reports target metadata I/O failures instead of an empty result', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('failed-metadata')) + const failure = new Error('target metadata unavailable') + vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValue(failure) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/internal', cause: failure }) + }) + + it('honors cancellation while waiting for target metadata', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('cancelled-metadata')) + const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockImplementation(async () => { + entered.resolve(undefined) + await finish.promise + return snapshot + }) + const abort = new AbortController() + const operation = fixture.controller.projections({ sessionId: parent.header.id }, abort.signal) + try { + await entered.promise + abort.abort(new Error('cancelled by caller')) + finish.resolve(undefined) + await expect(operation).rejects.toMatchObject({ code: 'gateway/cancelled' }) + } finally { + finish.resolve(undefined) + await Promise.allSettled([operation]) + } + }) + + it('rejects an already-cancelled read before metadata access', async () => { + const fixture = await harness() + const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat') + await expect(fixture.controller.projections({ sessionId: SessionId('cancelled') }, AbortSignal.abort())) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) + expect(stat).not.toHaveBeenCalled() + }) + + it('preserves Remote failures while reading the migration cache metadata', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('typed-failures'), {}, 3) + const failure = new RemoteError('gateway/internal', 'metadata failure', {}) + vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValueOnce(failure) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).rejects.toBe(failure) + }) + + it('rejects an empty identity without querying metadata', async () => { + const fixture = await harness() + const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat') + await expect(fixture.controller.projections({ sessionId: SessionId('') }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(stat).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts index 4d86abcf9e..591dd60985 100644 --- a/packages/api/session-controller/tests/remote/session.client.ts +++ b/packages/api/session-controller/tests/remote/session.client.ts @@ -145,7 +145,7 @@ export const sessionWorld: RemoteTable = { data: 'AA==', }), 'session/page': pageRule(ok({ records: [], hasMore: false })), - 'session/projections': ok({ asOfSeq: -1, values: { subagentCatalog: [] } }), + 'session/projections': ok({ kind: 'sequenced', asOfSeq: -1, values: { subagentCatalog: [] } }), 'subagents/prompt': ok({ messageId: 'fake-message' }), 'subagents/interruptByParent': ok({ accepted: true }), }, diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index aefd8b9288..a6b7a033dd 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -77,6 +77,53 @@ function conversationEvents(): SessionEvent[] { } describe('sessions.list cold merge', () => { + it('preserves listed format status and prefers live readiness without extra persistence reads', async () => { + const ctx = new Context() + try { + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const historical = header('format-historical', 100) + const current = header('format-current', 200) + const unknown = header('format-unknown', 300) + const shared = ctx.sessions.create(sid('format-shared'), { meta: { createdAt: 400, cwd: '/proj' } }).header + const liveOnly = ctx.sessions.create(sid('format-live'), { meta: { cwd: '/proj' } }) + const snapshots: SessionPersistenceSnapshot[] = [ + { ...statSnapshot(historical), formatStatus: 'migration-required' }, + { ...statSnapshot(current), formatStatus: 'current' }, + statSnapshot(unknown), + { ...statSnapshot(shared), formatStatus: 'migration-required' }, + ] + const list = vi.fn(async () => snapshots) + const stat = vi.fn() + const open = vi.fn() + ctx.provide('sessionPersistence', { list, stat, open } as never) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + try { + const response = await remote.list(request({})) + if (!response.ok) throw new Error('list failed') + const byId = new Map(response.value.items.map(item => [item.sessionId, item])) + expect(byId.get(historical.id)).toMatchObject({ formatStatus: 'migration-required' }) + expect(byId.get(current.id)).toMatchObject({ formatStatus: 'current' }) + expect(byId.get(unknown.id)).toMatchObject({ sessionId: unknown.id }) + expect(byId.get(unknown.id)).not.toHaveProperty('formatStatus') + expect(byId.get(shared.id)).toMatchObject({ formatStatus: 'current' }) + expect(byId.get(liveOnly.id)).toMatchObject({ formatStatus: 'current' }) + expect(list).toHaveBeenCalledTimes(1) + expect(stat).not.toHaveBeenCalled() + expect(open).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + } finally { + observe.mockRestore() + } + } finally { + await ctx.fiber.dispose() + } + }) + it('uses a predecessor title hint with zero cold stat or body reads', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 137eeeb190..e6ba8321ce 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -11,6 +11,7 @@ import { ToolCallId, createMessage, createUserMessage, ReasoningEffortId } from import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import SessionStore, { TOOL_OUTCOME_UNKNOWN, SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { Workspace } from '@deepseek-ai/dsh-workspace' @@ -95,6 +96,151 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, { }) describe('sessions.fork', () => { + it.each([ + { allowMigration: undefined, status: 'current' }, + { allowMigration: false, status: 'current' }, + { allowMigration: false, status: undefined }, + { allowMigration: undefined, status: 'migration-required' }, + { allowMigration: true, status: 'migration-required' }, + ] as const)('forks cold history with allowMigration=$allowMigration and format=$status', async ({ allowMigration, status }) => { + const ctx = await composed() + try { + const source = ctx.sessions.prepare(sid('current-cold'), { meta: { cwd: '/proj' } }) + source.append('turn/start', { turn: 1 }) + source.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const snapshot: SessionPersistenceSnapshot = { + header: source.header, + revision: SessionPersistenceRevision('test:cold-source'), + ...status === undefined ? {} : { formatStatus: status }, + } + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([source.header]), + stat: () => Promise.resolve(snapshot), + inspect: () => Promise.resolve({ meta: source.header, events: source.snapshotEvents() }), + }) as never) + const open = vi.spyOn(ctx.sessionPersistence, 'open') + + const response = await remote(ctx).fork({ + sessionId: source.id, + ...allowMigration === undefined ? {} : { allowMigration }, + }) + + if (!response.ok) throw response.error + expect(open).toHaveBeenCalledWith(source.id, 'read', undefined) + expect(ctx.sessions.get(source.id)).toBeUndefined() + expect(ctx.sessions.get(response.value.sessionId)?.inheritedEventCount).toBe(2) + } finally { + await ctx.fiber.dispose() + } + }) + + it('reports required migration before creating a fork when the caller disallows it', async () => { + const ctx = await composed() + try { + const sourceId = sid('requires-migration') + const source = ctx.sessions.prepare(sourceId, { meta: { cwd: '/proj' } }) + const snapshot: SessionPersistenceSnapshot = { + header: source.header, + revision: SessionPersistenceRevision('test:requires-migration'), + formatStatus: 'migration-required', + } + const stat = vi.fn(async () => snapshot) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) + const open = vi.spyOn(ctx.sessionPersistence, 'open') + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const create = vi.spyOn(ctx.agents, 'create') + + await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ + ok: false, error: { code: 'session/migration-required', details: { sessionId: sourceId } }, + }) + expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId) + expect(open).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + expect(create).not.toHaveBeenCalled() + } finally { + await ctx.fiber.dispose() + } + }) + + it('forks a resident Session without consulting disk metadata or requiring a live Agent', async () => { + const ctx = await composed() + try { + const source = ctx.sessions.create(sid('resident-source'), { meta: { cwd: '/proj' } }) + source.append('turn/start', { turn: 1 }) + source.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const stat = vi.fn(() => Promise.reject(new Error('unexpected metadata read'))) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) + + const result = await remote(ctx).fork({ sessionId: source.id, allowMigration: false }) + + if (!result.ok) throw result.error + expect(stat).not.toHaveBeenCalled() + expect(ctx.agents.get(source.id)).toBeUndefined() + expect(ctx.sessions.get(result.value.sessionId)?.inheritedEventCount).toBe(2) + } finally { + await ctx.fiber.dispose() + } + }) + + it('forks a source attached while its historical format stat is pending', async () => { + const ctx = await composed() + try { + const sourceId = sid('attached-during-stat') + const source = ctx.sessions.prepare(sourceId, { meta: { cwd: '/proj' } }) + const snapshot: SessionPersistenceSnapshot = { + header: source.header, + revision: SessionPersistenceRevision('test:attaching-source'), + formatStatus: 'migration-required', + } + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + const stat = vi.fn(() => { + entered.resolve(undefined) + return finish.promise + }) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) + const operation = remote(ctx).fork({ sessionId: sourceId, allowMigration: false }) + try { + await entered.promise + const live = ctx.sessions.create(sourceId, { meta: source.header }) + live.append('turn/start', { turn: 1 }) + live.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + finish.resolve(snapshot) + + const result = await operation + if (!result.ok) throw result.error + expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId) + expect(ctx.sessions.get(result.value.sessionId)?.inheritedEventCount).toBe(2) + } finally { + finish.resolve(undefined) + await Promise.allSettled([operation]) + } + } finally { + await ctx.fiber.dispose() + } + }) + + it('reports a stat failure without treating it as required migration or opening the source', async () => { + const ctx = await composed() + try { + const sourceId = sid('failed-source-stat') + const stat = vi.fn(() => Promise.reject(new Error('source metadata unavailable'))) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const create = vi.spyOn(ctx.agents, 'create') + + await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'source metadata unavailable' }, + }) + + expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId) + expect(observe).not.toHaveBeenCalled() + expect(create).not.toHaveBeenCalled() + } finally { + await ctx.fiber.dispose() + } + }) + it('keeps the completed manual compaction checkpoint when atSeq is omitted', async () => { const ctx = new Context() try { diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 5406d235f8..0fc3132fe7 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -1,4 +1,4 @@ -/** Session creation and adoption rules for Agent preset identity. */ +/** Session creation, blank-Session reuse, and Agent preset identity through `session.create`. */ import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -6,12 +6,14 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { afterEach, describe, expect, it } from 'vitest' -import { createSessionTestRemote } from './test-remote.ts' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts' /** Booted contexts and their temp roots, torn down after each test. */ const contexts: Context[] = [] @@ -25,53 +27,137 @@ function stubAgent(session: Session): Agent { return { id: session.id, session, status: 'idle' } as unknown as Agent } -function roster(ids: readonly string[]): unknown { - const presetOf = (id: string): object => ({ - id, - trust: 'system', - }) - return { - defaultId: ids[0], - resolve: (id?: string) => { - const wanted = id ?? ids[0] ?? '' - if (!ids.includes(wanted)) { - return Promise.reject(new RemoteError( - 'agent-preset/not-found', - `agent-presets: preset "${wanted}" not found (available: ${ids.join(', ') || 'none'})`, - { agentPreset: wanted, available: ids }, - )) - } - return Promise.resolve(presetOf(wanted)) - }, - mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), - } +/** A stored Session the persistence double serves until an Agent resumes it. */ +interface StoredSession { + readonly header: SessionHeader + readonly events: readonly SessionEvent[] } -async function harness(presets?: readonly string[]) { +interface HarnessOptions { + /** Declared presets; the first is the default. Omitted, no roster is composed. */ + readonly presets?: readonly string[] + /** Stored Sessions keyed by id. */ + readonly stored?: ReadonlyMap + /** Stored Sessions whose writer another owner holds. */ + readonly held?: ReadonlySet + /** Stored Sessions another resume in this process publishes while this one waits for the writer. */ + readonly publishedByOtherResume?: ReadonlySet + /** Stored Sessions whose storage fails to open for writing. */ + readonly unreadable?: ReadonlySet + /** Events a resume finds beyond the stored log, as when another Host wrote after the observation. */ + readonly writtenSinceObservation?: ReadonlyMap + /** Live Sessions whose preset switch the roster refuses as already started. */ + readonly locked?: ReadonlySet + /** Declared presets whose activation failed. */ + readonly broken?: ReadonlySet + /** Requested presets whose resolution waits for the paired promise. */ + readonly resolving?: ReadonlyMap> + /** Workspace placement served to `workspaceId` requests. */ + readonly workspace?: { readonly members: readonly SessionId[]; readonly archived: readonly SessionId[] } +} + +const WORKSPACE = 'workspace-1' as WorkspaceId + +async function harness(options: HarnessOptions = {}) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-'))) tempDirs.push(cwd) const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + const presets = options.presets + const selections: { sessionId: SessionId; agentPreset: string }[] = [] + const resumed: SessionId[] = [] if (presets !== undefined) { - ctx.provide('agentPresets', roster(presets) as never) + const known = (id?: string): string => { + const wanted = id ?? presets[0] ?? '' + if (!presets.includes(wanted)) { + throw new RemoteError('agent-preset/not-found', `Unknown agent preset: ${wanted}`, { + agentPreset: wanted, available: [...presets], + }) + } + return wanted + } + const usable = (id?: string): string => { + const wanted = known(id) + if (options.broken?.has(wanted) === true) { + throw new RemoteError('agent-preset/invalid', 'activation failed', { agentPreset: wanted, reason: 'activation failed' }) + } + return wanted + } + ctx.provide('agentPresets', { + defaultId: presets[0], + resolve: (id?: string) => Promise.resolve(id === undefined ? undefined : options.resolving?.get(id)) + .then(() => ({ id: known(id) })), + mount: (_ctx: Context, id?: string) => Promise.resolve().then(() => ({ id: usable(id) })), + select: async (agent: Agent, agentPreset: string) => { + usable(agentPreset) + if (options.locked?.has(agent.id) === true) { + throw new RemoteError('agent-preset/locked', 'This session has already started', { + sessionId: agent.id, agentPreset, + }) + } + selections.push({ sessionId: agent.id, agentPreset }) + agent.session.append('agent-preset/selected', { agentPreset }) + return agentPreset + }, + } as never) } - - const factory: AgentFactory = { - async createAgent(_ownerCtx, options) { - const session = ctx.sessions.create( - options.sessionId, - options.meta === undefined ? {} : { meta: options.meta }, - ) - const agent = stubAgent(session) - ;(agent as { ctx?: Context }).ctx = ctx - await options.setup?.(ctx, agent) - const unregister = await ctx.agents.register(agent) - return { agent, dispose: async () => { await unregister() } } + const stored = options.stored ?? new Map() + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([...stored.values()].map(record => record.header)), + inspect: (id: SessionId) => { + const record = stored.get(id) + return Promise.resolve(record === undefined ? undefined : { meta: record.header, events: [...record.events] }) }, - async resume() { - throw new Error('test harness has no persisted sessions') + }) as never) + const { members, archived } = options.workspace ?? { members: [], archived: [] } + const workspace = { id: WORKSPACE, path: cwd, sessionIds: members, attachSession: () => Promise.resolve() } + ctx.provide('workspaceRegistry', { + get: (id: WorkspaceId) => id === WORKSPACE ? workspace : undefined, + list: () => [workspace], + archivedSessionIds: archived, + } as never) + + /** Run setup on the unpublished Session, then publish it and its Agent, as the Agent factory does. */ + const publish = async ( + session: Session, + setup: Parameters[1]['setup'], + ): Promise<{ agent: Agent; dispose: () => Promise }> => { + const agent = stubAgent(session) + ;(agent as { ctx?: Context }).ctx = ctx + await setup?.(ctx, agent) + ctx.effect(function* () { + yield ctx.sessions.enter(session) + ctx.sessions.announce(session) + }, 'test: published Session') + const unregister = await ctx.agents.register(agent) + return { agent, dispose: async () => { await unregister() } } + } + const factory: AgentFactory = { + async createAgent(_ownerCtx, create) { + const session = ctx.sessions.prepare(create.sessionId, create.meta === undefined ? {} : { meta: create.meta }) + return publish(session, create.setup) + }, + async resume(_ownerCtx, resume) { + const id = resume.resumeSessionId + if (options.held?.has(id) === true) { + throw Object.assign(new Error(`session "${id}" is written by another owner`), { name: 'SessionAlreadyOwnedError' }) + } + const record = stored.get(id) + if (record === undefined || options.unreadable?.has(id) === true) { + throw new Error(`test persistence cannot open Session "${id}" for writing`) + } + if (options.publishedByOtherResume?.has(id) === true) { + await publish(ctx.sessions.prepare(id, { meta: record.header, seed: [...record.events] }), undefined) + throw Object.assign(new Error(`session "${id}" is written by this process`), { name: 'SessionAlreadyOwnedError' }) + } + resumed.push(id) + const session = ctx.sessions.prepare(id, { + meta: record.header, + seed: [...record.events, ...options.writtenSinceObservation?.get(id) ?? []], + }) + return publish(session, resume.setup) }, } ctx.agents.setFactory(factory) @@ -79,13 +165,43 @@ async function harness(presets?: readonly string[]) { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, }) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition) - return { ctx, remote } + const presetOf = (id: SessionId): string | null | undefined => { + const session = ctx.sessions.get(id) + return session === undefined ? undefined : ctx.sessionProjections.stateOf(session, 'agentPreset') + } + const selectedEvents = (id: SessionId): unknown[] => { + const session = ctx.sessions.get(id) + return session === undefined ? [] : session.snapshotEvents() + .filter(event => event.type === 'agent-preset/selected') + .map(event => event.data) + } + return { ctx, remote, cwd, selections, resumed, presetOf, selectedEvents } +} + +function storedHeader(id: SessionId, cwd: string, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false, cwd, ...extra } +} + +function modelSelection(seq: number): SessionEvent { + return { type: 'model/selection', seq: SessionSeq(seq), time: 1, data: { provider: 'test', model: 'test-model' } } +} + +function presetSelection(seq: number, agentPreset: string): SessionEvent { + return { type: 'agent-preset/selected', seq: SessionSeq(seq), time: 1, data: { agentPreset } } +} + +function completedTurn(seq: number): SessionEvent[] { + return [ + { type: 'turn/start', seq: SessionSeq(seq), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(seq + 1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] } describe('session.create Agent preset identity', () => { it('records the requested preset on the Session header', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) + const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] }) const created = await remote.create({ sessionId: SessionId('s1'), agentPreset: 'minimal' }) @@ -94,7 +210,7 @@ describe('session.create Agent preset identity', () => { }) it('records the roster default when the caller names no preset', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) + const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] }) await remote.create({ sessionId: SessionId('s2') }) @@ -102,55 +218,13 @@ describe('session.create Agent preset identity', () => { }) it('rejects an unknown preset', async () => { - const { remote } = await harness(['standard']) + const { remote } = await harness({ presets: ['standard'] }) const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' }) expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } }) }) - it('refuses to adopt a live Session under a different preset', async () => { - const { remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s4'), agentPreset: 'minimal' }) - - const response = await remote.create({ sessionId: SessionId('s4'), agentPreset: 'standard' }) - - expect(response).toMatchObject({ - ok: false, - error: { - code: 'agent-preset/conflict', - details: { - sessionId: 's4', - requestedPreset: 'standard', - existingPreset: 'minimal', - }, - }, - }) - }) - - it('adopts a live Session under the preset selected in its log', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) - ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) - - const adopted = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }) - const stale = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) - - expect(adopted).toMatchObject({ ok: true, value: { agentPreset: 'minimal' } }) - expect(stale).toMatchObject({ - ok: false, - error: { details: { existingPreset: 'minimal' } }, - }) - }) - - it('adopts a live Session unchanged when the caller names no preset', async () => { - const { remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s5'), agentPreset: 'minimal' }) - - await expect(remote.create({ sessionId: SessionId('s5') })) - .resolves.toMatchObject({ ok: true }) - }) - it('leaves the header preset-less when no roster is composed', async () => { const { ctx, remote } = await harness() @@ -158,6 +232,195 @@ describe('session.create Agent preset identity', () => { expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() }) +}) + +describe('session.create reuse of a live blank Session', () => { + it('recomposes a blank Session under the default when the caller names no preset', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + const reused = await remote.create({ sessionId: SessionId('draft') }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'standard' }]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('recomposes a blank Session under the named preset', async () => { + const { remote, selections } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + + const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'minimal' }]) + }) + + it('keeps a blank Session that already runs the requested preset', async () => { + const { ctx, remote, selections } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + ctx.sessions.get(SessionId('draft'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(selections).toEqual([]) + }) + + it('validates the requested preset before touching the blank Session', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'deleted' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } }) + expect(selections).toEqual([]) + expect(presetOf(SessionId('draft'))).toBe('minimal') + }) + + it('keeps the blank Session unchanged when the requested preset fails to activate', async () => { + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'broken'], + broken: new Set(['broken']), + }) + await remote.create({ sessionId: SessionId('draft') }) + + const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'broken' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/invalid' } }) + expect(selections).toEqual([]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('creates a new Session instead of reusing one that has started', async () => { + const { ctx, remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('talk'), agentPreset: 'minimal' }) + const talk = ctx.sessions.get(SessionId('talk')) + talk?.append('turn/start', { turn: 1 }) + talk?.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const created = await remote.create({ sessionId: SessionId('talk') }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('talk') + expect(created.value.agentPreset).toBe('standard') + expect(selections).toEqual([]) + expect(presetOf(SessionId('talk'))).toBe('minimal') + }) + + it('creates a new Session when a turn starts before the preset switch', async () => { + const { remote } = await harness({ + presets: ['standard', 'minimal'], + locked: new Set([SessionId('racing')]), + }) + await remote.create({ sessionId: SessionId('racing'), agentPreset: 'minimal' }) + + const created = await remote.create({ sessionId: SessionId('racing') }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('racing') + }) + + it('serializes concurrent reuse of one Session in request order', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + + const [first, second] = await Promise.all([ + remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }), + remote.create({ sessionId: SessionId('draft') }), + ]) + + expect(first).toMatchObject({ ok: true, value: { sessionId: 'draft' } }) + expect(second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([ + { sessionId: 'draft', agentPreset: 'minimal' }, + { sessionId: 'draft', agentPreset: 'standard' }, + ]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('claims one Session in arrival order when an earlier preset resolves later', async () => { + const minimal = Promise.withResolvers() + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'minimal'], resolving: new Map([['minimal', minimal.promise]]), + }) + await remote.create({ sessionId: SessionId('draft') }) + + const first = remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + const second = remote.create({ sessionId: SessionId('draft') }) + await new Promise(resolve => setTimeout(resolve, 0)) + minimal.resolve(undefined) + + expect(await first).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(await second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([ + { sessionId: 'draft', agentPreset: 'minimal' }, + { sessionId: 'draft', agentPreset: 'standard' }, + ]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('shares one creation between concurrent callers of a new identity', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + + const results = await Promise.all([ + remote.create({ sessionId: SessionId('fresh') }), + remote.create({ sessionId: SessionId('fresh') }), + ]) + + expect(results).toMatchObject([ + { ok: true, value: { sessionId: 'fresh' } }, + { ok: true, value: { sessionId: 'fresh' } }, + ]) + expect(ctx.agents.list().map(agent => agent.id)).toEqual(['fresh']) + }) + + it('refuses a Session in another directory', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-'))) + tempDirs.push(elsewhere) + await remote.create({ sessionId: SessionId('other'), cwd: elsewhere }) + + const response = await remote.create({ sessionId: SessionId('other') }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'session/conflict', details: { sessionId: 'other', existingCwd: elsewhere } }, + }) + expect(ctx.agents.list().map(agent => agent.id)).toEqual(['other']) + }) + + it('refuses a Session in another directory before resolving the requested preset', async () => { + const { remote } = await harness({ presets: ['standard'] }) + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-'))) + tempDirs.push(elsewhere) + await remote.create({ sessionId: SessionId('other'), cwd: elsewhere }) + + const response = await remote.create({ sessionId: SessionId('other'), agentPreset: 'nope' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/conflict', details: { sessionId: 'other' } } }) + }) + + it('leaves a subagent identity to subagent routing', async () => { + const { ctx, remote, cwd } = await harness({ presets: ['standard'] }) + ctx.sessions.create(SessionId('child'), { + meta: { cwd, parentSession: SessionId('parent'), origin: 'subagent' }, + }) + + const response = await remote.create({ sessionId: SessionId('child') }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) + }) + + it('reuses a preset-less blank Session unchanged when no roster is composed', async () => { + const { ctx, remote } = await harness() + await remote.create({ sessionId: SessionId('s5') }) + + const reused = await remote.create({ sessionId: SessionId('s5') }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 's5' } }) + expect(ctx.sessions.get(SessionId('s5'))?.header.agentPreset).toBeUndefined() + }) it('explains why a preset-less Session cannot be adopted under one', async () => { const { remote } = await harness() @@ -177,6 +440,235 @@ describe('session.create Agent preset identity', () => { }) if (response.ok) throw new Error('unreachable') expect('existingPreset' in response.error.details).toBe(false) - expect(response.error.message).toContain('records no agent preset') + expect(response.error.message).toBe('the deployment composes no agent preset roster, so session "s7" cannot run preset "standard"') + }) +}) + +describe('session.create reuse of a stored blank Session', () => { + it('resumes a blank Session whose recorded preset is no longer declared under the default', async () => { + const draft = SessionId('stale-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'stale-draft', agentPreset: 'standard' } }) + expect(resumed).toEqual(['stale-draft']) + expect(presetOf(draft)).toBe('standard') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'standard' }]) + }) + + it('resumes a blank Session under the default even when its previous choice is still declared', async () => { + const draft = SessionId('picked-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'picked-draft', agentPreset: 'standard' } }) + expect(presetOf(draft)).toBe('standard') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }, { agentPreset: 'standard' }]) + }) + + it('resumes a blank Session under its recorded preset when the caller names it', async () => { + const draft = SessionId('restored-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd, agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'restored-draft', agentPreset: 'minimal' } }) + expect(presetOf(draft)).toBe('minimal') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }]) + }) + + it('rejects a named preset that is no longer declared without resuming the Session', async () => { + const draft = SessionId('stale-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]), + }) + + const response = await remote.create({ sessionId: draft, cwd, agentPreset: 'liangshen' }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent-preset/not-found', details: { agentPreset: 'liangshen', available: ['standard'] } }, + }) + expect(resumed).toEqual([]) + expect(ctx.agents.list()).toEqual([]) + }) + + it('creates a new Session instead of resuming one that has started', async () => { + const talk = SessionId('stored-talk') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[talk, { header: storedHeader(talk, cwd, { agentPreset: 'liangshen' }), events: completedTurn(0) }]]), + }) + + const created = await remote.create({ sessionId: talk, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('stored-talk') + expect(created.value.agentPreset).toBe('standard') + expect(resumed).toEqual([]) + }) + + it('creates a new Session when another owner holds the blank Session', async () => { + const draft = SessionId('held-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]), + held: new Set([draft]), + }) + + const created = await remote.create({ sessionId: draft, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('held-draft') + expect(ctx.sessions.get(draft)).toBeUndefined() + }) + + it('reuses the Agent another resume published while holding the writer', async () => { + const draft = SessionId('contended-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]), + publishedByOtherResume: new Set([draft]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'contended-draft', agentPreset: 'standard' } }) + expect(selections).toEqual([{ sessionId: 'contended-draft', agentPreset: 'standard' }]) + expect(presetOf(draft)).toBe('standard') + }) + + it('reports an unreadable Session instead of creating another one', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) + + const response = await remote.create({ sessionId: SessionId('unreadable') }) + + expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + expect(ctx.agents.list()).toEqual([]) + }) + + it('reports a storage failure instead of creating another Session', async () => { + const draft = SessionId('unreadable-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]), + unreadable: new Set([draft]), + }) + + const response = await remote.create({ sessionId: draft, cwd }) + + expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + expect(ctx.agents.list()).toEqual([]) + }) + + it('creates a new Session when the blank Session started after it was observed', async () => { + const draft = SessionId('overtaken-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote, resumed } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]), + writtenSinceObservation: new Map([[draft, completedTurn(1)]]), + }) + + const created = await remote.create({ sessionId: draft, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('overtaken-draft') + expect(resumed).toEqual(['overtaken-draft']) + expect(ctx.sessions.get(draft)).toBeUndefined() + }) + + it('refuses a stored Session in another directory', async () => { + const other = SessionId('stored-other') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[other, { header: storedHeader(other, '/elsewhere'), events: [modelSelection(0)] }]]), + }) + + const response = await remote.create({ sessionId: other, cwd }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'session/conflict', details: { sessionId: 'stored-other', existingCwd: '/elsewhere' } }, + }) + expect(resumed).toEqual([]) + }) + + it('leaves a stored subagent identity to subagent routing', async () => { + const child = SessionId('stored-child') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[child, { + header: storedHeader(child, cwd, { parentSession: SessionId('parent'), origin: 'subagent' }), + events: [modelSelection(0)], + }]]), + }) + + const response = await remote.create({ sessionId: child, cwd }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) + expect(resumed).toEqual([]) + }) +}) + +describe('session.create Workspace placement', () => { + it('reuses a blank member of the requested Workspace', async () => { + const draft = SessionId('member-draft') + const { remote } = await harness({ presets: ['standard'], workspace: { members: [draft], archived: [] } }) + await remote.create({ sessionId: draft }) + + const reused = await remote.create({ sessionId: draft, workspaceId: WORKSPACE }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'member-draft' } }) + }) + + it.each([ + { name: 'outside the Workspace', members: [], archived: [] }, + { name: 'archived', members: [SessionId('placed-draft')], archived: [SessionId('placed-draft')] }, + ])('creates a new Session instead of reusing a blank Session $name', async ({ members, archived }) => { + const draft = SessionId('placed-draft') + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'], workspace: { members, archived } }) + await remote.create({ sessionId: draft, agentPreset: 'minimal' }) + + const created = await remote.create({ sessionId: draft, workspaceId: WORKSPACE }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('placed-draft') + expect(selections).toEqual([]) + expect(presetOf(draft)).toBe('minimal') }) }) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index e25339bc93..d92a83198e 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -79,6 +79,31 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise { } describe('list store projection', () => { + it('publishes format-status changes and preserves a newer update over an in-flight list', async ({ bench }) => { + const b = bench() + const historical = { + sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, agentAvailable: false, + formatStatus: 'migration-required' as const, + } + b.mock.remote.session.list.mockResolvedValue(ok({ items: [historical] })) + await b.svc.refresh() + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('migration-required') + + const pending = Promise.withResolvers>>() + b.unblock.push(() => { pending.resolve(ok({ items: [historical] })) }) + b.mock.remote.session.list.mockReturnValueOnce(pending.promise) + const refreshing = b.svc.refresh() + b.svc.handleSessionAdded({ ...historical, formatStatus: 'current' }) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('current') + pending.resolve(ok({ items: [historical] })) + await refreshing + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('current') + expect(b.mock.remote.session.projections).not.toHaveBeenCalled() + }) + it('projects durable titles separately from cwd/id display fallbacks and parent links', async ({ bench }) => { const b = bench() b.svc.handleControlFrame({ @@ -730,7 +755,7 @@ describe('catalog-addressed navigation', () => { const b = bench() await feedList(b, [{ id: 'root' }]) b.mock.remote.session.projections.mockResolvedValue(ok({ - asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'continuable', label: 'Child' }] }, + kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'continuable', label: 'Child' }] }, })) await b.svc.refreshProjections(sid('root')) const address = b.svc.subagentAddress(sid('child'))! @@ -761,7 +786,7 @@ describe('catalog-addressed navigation', () => { type: 'projection', sessionId: sid('one-shot'), key: 'title', value: title, seq: 2, }) b.mock.remote.session.projections.mockResolvedValue(ok({ - asOfSeq: 0, values: { subagentCatalog: [{ id: sid('one-shot'), createdAt: 1, mode: 'one-shot' }] }, + kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: sid('one-shot'), createdAt: 1, mode: 'one-shot' }] }, })) await b.svc.refreshProjections(sid('root')) expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe(title ?? 'one-shot') @@ -780,16 +805,16 @@ describe('catalog-addressed navigation', () => { b.mock.remote.session.projections.mockImplementation((payload) => { const { sessionId } = payload as { sessionId: SessionId } if (sessionId === sid('root')) { - return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: sid('child'), mode: 'continuable', label: 'Child', }] } })) } if (sessionId === sid('child')) { - return Promise.resolve(ok({ asOfSeq: 0, values: { title: 'Child session title', subagentCatalog: [{ createdAt: 1, + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { title: 'Child session title', subagentCatalog: [{ createdAt: 1, id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', }] } })) } - return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } })) + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) }) await feedList(b, [ { id: 'root' }, @@ -819,16 +844,16 @@ describe('catalog-addressed navigation', () => { b.mock.remote.session.projections.mockImplementation((payload) => { const { sessionId } = payload as { sessionId: SessionId } if (sessionId === sid('root')) { - return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: sid('child'), mode: 'continuable', label: 'Child', }] } })) } if (sessionId === sid('child')) { - return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', }] } })) } - return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } })) + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) }) await feedList(b, [{ id: 'root' }]) await b.svc.refreshProjections(sid('root')) @@ -907,6 +932,38 @@ describe('create', () => { }) describe('fork', () => { + it.for([undefined, false, true])('forwards migration permission %s without opening the source', async (allowMigration, { bench }) => { + const b = bench() + b.mock.remote.session.fork.mockResolvedValueOnce(ok({ sessionId: sid('child') })) + const source = sid('source') + const permission = allowMigration === undefined ? {} : { allowMigration } + + await expect(b.svc.fork({ sessionId: source, atSeq: 7, ...permission })).resolves.toBe(sid('child')) + + expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: source, atSeq: 7, ...permission }) + expect(b.svc.binding(source)).toBeUndefined() + expect(b.mock.log.requests(FOLLOW)).toHaveLength(0) + }) + + it('preserves the Host migration refusal without creating or opening a Session', async ({ bench }) => { + const b = bench() + const source = sid('source') + const error = new RemoteError('session/migration-required', 'migration required', { sessionId: source }) + const onCreated = vi.fn<(childId: SessionId) => void>() + b.mock.remote.session.fork.mockResolvedValueOnce(err(error)) + + const failure = await b.svc.fork({ sessionId: source, allowMigration: false, onCreated }) + .catch((cause: unknown) => cause) + + expect(failure).toBeInstanceOf(SessionForkError) + expect(failure).toMatchObject({ sourceSessionId: source, rpcError: error }) + expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: source, allowMigration: false }) + expect(onCreated).not.toHaveBeenCalled() + expect(b.mock.remote.session.rename).not.toHaveBeenCalled() + expect(b.svc.list.getSnapshot().ids).toEqual([]) + expect(b.mock.log.requests(FOLLOW)).toHaveLength(0) + }) + it('propagates a failed fork without creating or retaining a child', async ({ bench }) => { const b = bench() const error = new RemoteError('session/not-found', 'source missing', { sessionId: sid('source') }) diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts index 4d9c4360fc..de1852991a 100644 --- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts +++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts @@ -2,12 +2,10 @@ import { Context } from '@deepseek-ai/cordis' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionSeq, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' -import { SessionQueryError } from '@deepseek-ai/dsh-session-query' -import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createSessionTestController, installSessionReadTestServices, @@ -41,8 +39,15 @@ const events: SessionEvent[] = [{ }, }] +const contexts: Context[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + async function bench() { const ctx = new Context() + contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { @@ -82,20 +87,48 @@ describe('SessionController subagent catalog', () => { } }) - it('requests a stored parent catalog by id without loading an Agent', async () => { + it.each([false, true])('reads an uncached parent catalog without loading an Agent (explicit current format: %s)', async (withFormat) => { const { ctx, controller } = await bench() + const stored = await ctx.sessionPersistence.stat(PARENT) + if (stored === undefined) throw new Error('parent fixture is missing') + const stat = vi.spyOn(ctx.sessionPersistence, 'stat').mockResolvedValueOnce(withFormat + ? { ...stored, formatStatus: 'current' } + : stored) const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const open = vi.spyOn(ctx.sessionPersistence, 'open') + const resume = vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new Error('unexpected Agent resume')) const signal = new AbortController().signal - await expect(controller.projections({ sessionId: PARENT }, signal)).resolves.toMatchObject({ values: { subagentCatalog: [{ - id: CHILD, - mode: 'continuable', - label: 'worker', - }] } }) - expect(observe).toHaveBeenCalledOnce() - expect(observe).toHaveBeenCalledWith(PARENT, { - signal, + await expect(controller.projections({ sessionId: PARENT }, signal)).resolves.toMatchObject({ + kind: 'sequenced', values: { subagentCatalog: [{ id: CHILD, mode: 'continuable', label: 'worker' }] }, }) + expect(stat).toHaveBeenCalledWith(PARENT, { signal }) + expect(observe).toHaveBeenCalledWith(PARENT, { signal }) + expect(open).toHaveBeenCalledWith(PARENT, 'read', { signal }) + expect(resume).not.toHaveBeenCalled() + expect(ctx.sessions.get(PARENT)).toBeUndefined() + expect(ctx.agents.get(PARENT)).toBeUndefined() + }) + + it('uses disk migration status instead of the normalized header version to defer a cold catalog read', async () => { + const { ctx, controller } = await bench() + const stored = await ctx.sessionPersistence.stat(PARENT) + if (stored === undefined) throw new Error('parent fixture is missing') + vi.spyOn(ctx.sessionPersistence, 'stat').mockResolvedValueOnce({ + ...stored, + formatStatus: 'migration-required', + }) + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const open = vi.spyOn(ctx.sessionPersistence, 'open') + + expect(stored.header.version).toBe(SESSION_FORMAT_VERSION) + await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)) + .resolves.toEqual({ kind: 'migration-required', values: {} }) + + expect(observe).not.toHaveBeenCalled() + expect(open).not.toHaveBeenCalled() + expect(ctx.sessions.get(PARENT)).toBeUndefined() + expect(ctx.agents.get(PARENT)).toBeUndefined() }) it('serves a live parent from its maintained registry state without folding its log', async () => { @@ -113,7 +146,7 @@ describe('SessionController subagent catalog', () => { const hydrate = vi.spyOn(ctx.sessionProjections, 'hydrate') const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal) - expect(result).toMatchObject({ values: { subagentCatalog: [{ + expect(result).toMatchObject({ kind: 'sequenced', values: { subagentCatalog: [{ id: CHILD, mode: 'one-shot', label: 'live worker', @@ -140,7 +173,7 @@ describe('SessionController subagent catalog', () => { const unavailable = await controller.list({}, new AbortController().signal) expect(unavailable.items.find(item => item.sessionId === PARENT)?.agentAvailable).toBe(false) const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal) - expect(result).toMatchObject({ asOfSeq: 0, values: { subagentCatalog: [{ id: CHILD, createdAt: 8, mode: 'one-shot' }] } }) + expect(result).toMatchObject({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: CHILD, createdAt: 8, mode: 'one-shot' }] } }) }) it('does not republish a Session when its Agent outlives the Session registration', async () => { @@ -158,6 +191,7 @@ describe('SessionController subagent catalog', () => { it('reads registered projections without a subagent provider or catalog key', async () => { const ctx = new Context() + contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) @@ -165,7 +199,8 @@ describe('SessionController subagent catalog', () => { const session = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } }) const expected = ctx.sessionProjections.snapshot(session) expect(expected.values.subagentCatalog).toBeUndefined() - await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).resolves.toEqual(expected) + await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)) + .resolves.toEqual({ kind: 'sequenced', ...expected }) expect(ctx.agents.get(PARENT)).toBeUndefined() }) @@ -183,36 +218,19 @@ describe('SessionController subagent catalog', () => { }) }) - it('maps query cancellation, missing projections, and unexpected failures', async () => { + it('reports a live snapshot failure without falling back to stored metadata', async () => { const { ctx, controller } = await bench() - const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') - observe.mockRejectedValueOnce(new SessionQueryError( - 'query cancelled', - 'SESSION_QUERY_ABORTED', - )) - await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({ - code: 'gateway/cancelled', + const parent = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } }) + const failure = new Error('projection snapshot failed') + const snapshot = vi.spyOn(ctx.sessionProjections, 'snapshot').mockImplementationOnce(() => { + throw failure }) - - observe.mockRestore() - // An observation without the catalog state means no registry served it. - const stateless: SessionObservation = { - source: 'prepared', - header, - inheritedEventCount: SessionLogOffset(0), - events, - cursor: SessionSeq(0), - retain: () => stateless, - [Symbol.dispose]: () => {}, - } - vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValueOnce(stateless) - await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({ - code: 'session/projections-unavailable', - }) - - vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValueOnce(new Error('storage offline')) + const stat = vi.spyOn(ctx.sessionPersistence, 'stat') await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/internal', + cause: failure, }) + expect(snapshot).toHaveBeenCalledWith(parent) + expect(stat).not.toHaveBeenCalled() }) }) diff --git a/packages/api/session-controller/tsdown.config.ts b/packages/api/session-controller/tsdown.config.ts index bc2abfa538..0e42a1163e 100644 --- a/packages/api/session-controller/tsdown.config.ts +++ b/packages/api/session-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-session-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/settings-controller/locale/en.json b/packages/api/settings-controller/locale/en.json new file mode 100644 index 0000000000..ccd029abde --- /dev/null +++ b/packages/api/settings-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-settings-controller" + } +} diff --git a/packages/api/settings-controller/locale/zh.json b/packages/api/settings-controller/locale/zh.json new file mode 100644 index 0000000000..5e1155ba4d --- /dev/null +++ b/packages/api/settings-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "设置读写接口", + "description": "向客户端提供各项设置的读取与修改能力。" + } +} diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index ded8c8bf22..6c72102b61 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -31,9 +31,11 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", @@ -45,7 +47,7 @@ "license": "MIT", "dependencies": { "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/terminal-controller/locale/en.json b/packages/api/terminal-controller/locale/en.json new file mode 100644 index 0000000000..a505798bad --- /dev/null +++ b/packages/api/terminal-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-terminal-controller" + } +} diff --git a/packages/api/terminal-controller/locale/zh.json b/packages/api/terminal-controller/locale/zh.json new file mode 100644 index 0000000000..777c03f31c --- /dev/null +++ b/packages/api/terminal-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "终端控制接口", + "description": "向客户端提供会话终端的创建、输入、读取和恢复能力。" + } +} diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 9b67bd26cc..d9ade0d77a 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-terminal-controller", "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -35,7 +35,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -60,7 +61,7 @@ "@deepseek-ai/schemastery": "workspace:~", "@xterm/headless": "^6.0.0", "@xterm/addon-serialize": "^0.14.0", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/dsh-subprocess": "workspace:*", @@ -84,6 +85,7 @@ "@deepseek-ai/dsh-working-directory": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", diff --git a/packages/api/terminal-controller/tsdown.config.ts b/packages/api/terminal-controller/tsdown.config.ts index cb9d252eb2..0e42a1163e 100644 --- a/packages/api/terminal-controller/tsdown.config.ts +++ b/packages/api/terminal-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-terminal-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/workspace-controller/locale/en.json b/packages/api/workspace-controller/locale/en.json new file mode 100644 index 0000000000..233cc2ae0c --- /dev/null +++ b/packages/api/workspace-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-workspace-controller" + } +} diff --git a/packages/api/workspace-controller/locale/zh.json b/packages/api/workspace-controller/locale/zh.json new file mode 100644 index 0000000000..43275acd31 --- /dev/null +++ b/packages/api/workspace-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "工作区控制接口", + "description": "向客户端提供工作区操作和可在重连后恢复的状态。" + } +} diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 118a4772a9..ac9d45a3e1 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -39,7 +39,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -58,6 +59,7 @@ "watch": "tsdown --watch" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -72,7 +74,7 @@ "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-native-command": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/workspace-controller/tsdown.config.ts b/packages/api/workspace-controller/tsdown.config.ts index a79662aebc..0e42a1163e 100644 --- a/packages/api/workspace-controller/tsdown.config.ts +++ b/packages/api/workspace-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-workspace-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/workspace-files/locale/en.json b/packages/api/workspace-files/locale/en.json new file mode 100644 index 0000000000..63caa3de7d --- /dev/null +++ b/packages/api/workspace-files/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-workspace-files" + } +} diff --git a/packages/api/workspace-files/locale/zh.json b/packages/api/workspace-files/locale/zh.json new file mode 100644 index 0000000000..eebfab4170 --- /dev/null +++ b/packages/api/workspace-files/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "工作区文件接口", + "description": "提供工作区文件读取、目录列表和文件信息更新。" + } +} diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index 01a35b7da2..f77a965ad0 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-files", "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -35,7 +35,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,7 +56,7 @@ "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" @@ -74,6 +75,7 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", diff --git a/packages/api/workspace-files/tsdown.config.ts b/packages/api/workspace-files/tsdown.config.ts index 6bf04a7409..0e42a1163e 100644 --- a/packages/api/workspace-files/tsdown.config.ts +++ b/packages/api/workspace-files/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-workspace-files', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/attachment/attachment-local/locale/en.json b/packages/attachment/attachment-local/locale/en.json new file mode 100644 index 0000000000..7d67c258a1 --- /dev/null +++ b/packages/attachment/attachment-local/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "attachment-local" + } +} diff --git a/packages/attachment/attachment-local/locale/zh.json b/packages/attachment/attachment-local/locale/zh.json new file mode 100644 index 0000000000..c0116cbbfe --- /dev/null +++ b/packages/attachment/attachment-local/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "本地附件存储", + "description": "保存会话中的图片等附件,并按内容标识读取。" + } +} diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 810513ab0e..f40c826a40 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -14,11 +14,19 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "exports": { - ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, - "files": ["lib/index.js", "lib/types/**/*.d.ts"], + "files": [ + "locale/*.json", + "lib/index.js", + "lib/types/**/*.d.ts" + ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:*", @@ -28,7 +36,7 @@ "dependencies": { "@deepseek-ai/dsh-lazy-require": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "sharp": "^0.35.5" + "sharp": "0.35.5" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:*", diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index e119eb4700..be1f97916f 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index d34ab41f33..4468b0fd9a 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -21,8 +21,8 @@ en: 2fdfe02f2ed4a258 zh: d748228ece579c4a /deepseek-ai-dsh-app-boot/use-this-package/starting-the-app: - en: 1099528bde0e2aee - zh: b68772bdb9bb7856 + en: 4a424cd401556f20 + zh: 0b34dd18e0db6cea /deepseek-ai-dsh-app-boot/use-this-package/profiles: en: d50b0fdaf62353dd zh: 0fccb6968602e5de @@ -36,11 +36,8 @@ en: a9afb263b03d112c zh: 31f5e797cd953c15 /deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures: - en: 09890e218ab49c84 - zh: e3d86597e7fb1adb -/deepseek-ai-dsh-app-boot/use-this-package/telling-the-agent-where-the-harness-lives: - en: 07c459f26f4f8517 - zh: 210f1e4894731b1a + en: 206c9093965bc5e1 + zh: d8525e950595c399 /deepseek-ai-dsh-app-boot/understand-the-implementation: en: f4a2695cb4c8a570 zh: 89837235d2f376cf @@ -48,20 +45,20 @@ en: 25a0d1b46fd66d68 zh: 5cd8b4ac382dccdc /deepseek-ai-dsh-app-boot/understand-the-implementation/helper-behavior: - en: f7bc3a8dd4b00bdd - zh: 30098428aa769ad0 + en: 6fa850d1bb000d0e + zh: aa83a40a9d3db295 /deepseek-ai-dsh-app-boot/understand-the-implementation/source-map: - en: 3da01827e3354655 - zh: 06ccfaed83f65a76 + en: 541700d5c39e0ab4 + zh: 7522dbd9cd68c930 /deepseek-ai-dsh-app-boot/further-exploration: en: 401269cc5ea8593e zh: cd337c356b320565 /deepseek-ai-dsh-app-boot/model-experience: - en: 769777f1cb1a9408 - zh: f02b2834179df50d + en: d2d0a21502d88c50 + zh: 82a60c7a490a6834 /deepseek-ai-dsh-app-boot/model-experience/kv-cache-effect: - en: c4d49630b68ae896 - zh: 6f450ec6ba39bb81 + en: 5a1d9344c8fdc790 + zh: 0b32579f658b2ca1 /deepseek-ai-dsh-app-boot/known-limitations-and-deferred-work: en: bf1a49013cad8e9c zh: 41b2b010f8650ec3 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 09a9ebd0c7..cba4c934e4 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -40,7 +40,7 @@ installFailLoud('dsh') const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) ``` -`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. +`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `web-runtime`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. ### Profiles @@ -116,10 +116,6 @@ The [Web process matrix](../../../apps/cli/tests/profiles/web/tests/web-failure- If your app owns the terminal, it can hand the terminal back before the process exits, so your shell is never left in raw mode. The handoff is bounded: a stuck cleanup delays the fatal exit but never cancels it. -### Telling the agent where the harness lives - -When your app boots a model-backed agent, you can tell the agent where the DSH implementation checkout lives: it learns that path and that it must not infer the working directory from it — it should use `pwd`. The instruction appears once near the top of the system prompt. Apps without a system prompt service skip it; in development, reloading the system prompt drops it until the next boot. - ----- @@ -151,13 +147,13 @@ The startup error also retains inactive-entry metadata and raw startup warning/e ### Helper behavior -The exports each own one stage of the boot: config resolution and snapshot replay, layered environment loading, fail-loud reporting, activation auditing, patch parsing, root-include mounting, config dump rendering, profile composition, and the harness-source section. Per-export contracts live in the code, not this README — see [`src/index.ts`](src/index.ts) and [`src/profile.ts`](src/profile.ts). +The exports each own one stage of the boot: config resolution and snapshot replay, layered environment loading, fail-loud reporting, activation auditing, patch parsing, root-include mounting, config dump rendering, and profile composition. Per-export contracts live in the code, not this README — see [`src/index.ts`](src/index.ts) and [`src/profile.ts`](src/profile.ts). ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section | +| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump | | [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, runtime resolution construction | | [`src/profile-plugins.ts`](src/profile-plugins.ts) | Installed dependencies, bundle activation policy, and manifest updates | | [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | Profile patch backup and recovery bundle activation | @@ -187,11 +183,11 @@ Read these pages when the package-level contract is not enough. They move from t ## Model Experience -Indirectly, through the loaded plugin tree, which alone contributes model context; the one export that adds model-visible text, `addHarnessSourceSection`, does so only when a consumer calls it after boot. +Indirectly, through the loaded plugin tree, which alone contributes model context; no export adds model-visible text. #### KV Cache effect -Boot itself changes no request prefix. `addHarnessSourceSection` places its source path after first-party reusable instructions, so different checkouts leave those preceding bytes unchanged when tools and configuration match. Provider cache reuse is not guaranteed. +Boot itself changes no request prefix. ## Known Limitations and Deferred Work diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 5199c9a23d..cb383af462 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -40,7 +40,7 @@ installFailLoud('dsh') const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) ``` -`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。 +`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`web-runtime`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。 ### Profile @@ -116,10 +116,6 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。 -### 告诉 agent(智能体)harness 所在位置 - -当你的应用启动模型驱动的 agent 时,你可以告诉 agent DSH 实现代码 checkout 的位置:它得知该路径,也知道不得据此推断工作目录——它应使用 `pwd`。这条指示在系统提示词靠前位置出现一次。没有系统提示词服务的应用会跳过;开发环境中,重新加载系统提示词后它会消失,直至下次启动。 - ----- @@ -151,13 +147,13 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 ### Helper 行为 -每个导出各负责启动的一个阶段:配置解析与快照回放、分层环境加载、明确报错的保护机制、激活审计、patch 解析、根 include 挂载、配置 dump 渲染、profile 组合,以及 harness 源码段落。各导出的约定在代码中,不在本 README——见 [`src/index.ts`](src/index.ts) 与 [`src/profile.ts`](src/profile.ts)。 +每个导出各负责启动的一个阶段:配置解析与快照回放、分层环境加载、明确报错的保护机制、激活审计、patch 解析、根 include 挂载、配置 dump 渲染与 profile 组合。各导出的约定在代码中,不在本 README——见 [`src/index.ts`](src/index.ts) 与 [`src/profile.ts`](src/profile.ts)。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump、harness 源码段落 | +| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump | | [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、runtime resolution 构造 | | [`src/profile-plugins.ts`](src/profile-plugins.ts) | 已安装依赖、bundle 启用策略与 manifest 更新 | | [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | profile patch 备份与恢复 bundle 启用状态 | @@ -187,11 +183,11 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 ## 模型体验 -模型通过此包加载的插件树间接受影响——只有该树贡献模型上下文;唯一贡献模型可见文本的导出 `addHarnessSourceSection`,也只有在消费方启动后调用它时才会产生影响。 +模型通过此包加载的插件树间接受影响——只有该树贡献模型上下文;没有导出会添加模型可见文本。 #### KV Cache 影响 -启动本身不改变请求前缀。`addHarnessSourceSection` 将源码路径放在第一方可复用指令之后,因此工具与配置一致时,不同 checkout 不会改变前置字节。不保证提供方复用缓存。 +启动本身不改变请求前缀。 ## 已知限制与延期工作 diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index af2930239b..5717ca71cc 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -37,7 +37,7 @@ "@eslint-community/regexpp": "^4.12.2", "ajv": "^8.20.0", "js-yaml": "^4.3.2", - "node-addon-require-builtin": "^0.1.6", + "node-addon-require-builtin": "^0.1.9", "resolve.exports": "^2.0.3", "semver": "^7.8.5" }, @@ -47,7 +47,6 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index e042c4e458..f5b08a4596 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -31,7 +31,6 @@ export { ON_DEMAND_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, type OfficialBundleCatal export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts' export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts' export { isNativeConfigSchema } from './config-schema/native.ts' -import type {} from '@deepseek-ai/dsh-system-prompt' export { readProfilePlugins, reconcileProfilePlugins, writeProfileBundles, @@ -748,6 +747,7 @@ const FIBER_DISPOSED = 4 as FiberState.DISPOSED const requiredStartupEntryIds = new Set([ 'agent-loop', 'webserver', + 'web-runtime', 'modules', 'connection', 'headless-runner', @@ -1038,31 +1038,3 @@ export async function boot( await diagnostics.fiber.dispose() } } - -/** Prompt-section name for the harness-source location line an app bin adds after boot. */ -export const HARNESS_SOURCE_SECTION = 'harness:source' - -/** - * Add a global prompt section naming the on-disk harness source checkout while - * explicitly distinguishing it from the task workspace and current working - * directory. The self-referential `dsh-tool-cordis` toolset reads and edits this - * checkout. Call once on the settled boot context ({@link boot}); the section - * uses the shared first-party placement after reusable instructions - * and before the Web surface and persona suffix. A booted tree with no - * `systemPrompt` service has no prompt to augment, so this is then a no-op - * that returns `undefined`. The section is - * registered against the `systemPrompt` service's fiber, so a dev HMR reload of - * that plugin drops it until the next boot. - * @param ctx - the settled boot context whose global system prompt to augment. - * @param sourceRoot - the absolute path to the harness checkout root. - * @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted. - */ -export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() => void) | undefined { - const systemPrompt = ctx.get('systemPrompt') - if (systemPrompt === undefined) return undefined - return systemPrompt.section({ - name: HARNESS_SOURCE_SECTION, - order: systemPrompt.getSectionOrder('HARNESS_SOURCE'), - text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`, - }) -} diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index f291e45cc6..826e4fa368 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -5,10 +5,10 @@ import { pathToFileURL } from 'node:url' import { inspect } from 'node:util' import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-system-prompt' import { - addHarnessSourceSection, auditStartupEntries, boot, StartupError, - FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, + auditStartupEntries, boot, StartupError, + FAIL_LOUD_RELEASE_TIMEOUT_MS, installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudEvent, type FailLoudProcess, } from '../src/index.ts' @@ -558,6 +558,7 @@ describe('auditStartupEntries', () => { const requiredIds = [ 'agent-loop', 'webserver', + 'web-runtime', 'modules', 'connection', 'headless-runner', @@ -739,7 +740,7 @@ describe('auditStartupEntries', () => { it('omits an error cause when required plugins are only waiting for services', async () => { const error = await auditStartupEntries(ctxWith([ - { fiber: fiber(0, undefined, { webRuntime: {} }), options: { id: 'connection', name: './connection.mjs' } }, + { fiber: fiber(0, undefined, { webStartup: {} }), options: { id: 'connection', name: './connection.mjs' } }, ]), NAME, vi.fn()).catch((error: unknown) => error) expect(error).toBeInstanceOf(StartupError) expect(Object.hasOwn(error as StartupError, 'cause')).toBe(false) @@ -750,9 +751,9 @@ describe('auditStartupEntries', () => { it('keeps diagnostic metadata available without expanding it in ordinary error inspection', () => { const entries = [{ id: 'connection', module: './connection.mjs', required: true, fiberState: 0, - outcome: { kind: 'pending' as const, missing: ['webRuntime'] }, + outcome: { kind: 'pending' as const, missing: ['webStartup'] }, }] - const error = new StartupError('waiting for webRuntime', entries) + const error = new StartupError('waiting for webStartup', entries) const startup = { configurationPath: '/private/cordis.yml', messages: [ { ts: 1, name: 'loader', type: 'warn', args: ['raw diagnostic argument'] }, ] } @@ -760,7 +761,7 @@ describe('auditStartupEntries', () => { expect(error.entries).toBe(entries) expect(error.startup).toBe(startup) const output = inspect(error) - expect(output).toContain('waiting for webRuntime') + expect(output).toContain('waiting for webStartup') expect(output).not.toContain('connection.mjs') expect(output).not.toContain('/private/cordis.yml') expect(output).not.toContain('raw diagnostic argument') @@ -792,12 +793,12 @@ describe('auditStartupEntries', () => { const error = await auditStartupEntries(ctxWith([ { fiber: fiber(0, undefined, { webServer: {} }), options: { id: 'web-runtime', name: './web.mjs' } }, { fiber: fiber(3, original), options: { id: 'webserver', name: '@deepseek-ai/dsh-host-webserver' } }, - { fiber: fiber(0, undefined, { webRuntime: {} }), options: { id: 'connection', name: './connection.mjs' } }, + { fiber: fiber(0, undefined, { webStartup: {} }), options: { id: 'connection', name: './connection.mjs' } }, { fiber: fiber(0), options: { id: 'unknown', name: './unknown.mjs' } }, ]), NAME, warn).catch((error: unknown) => error) expect(error).toBeInstanceOf(StartupError) expect((error as Error).message).toMatchInlineSnapshot(` - "dsh-test-bin: startup failed: 2 required plugins did not activate + "dsh-test-bin: startup failed: 3 required plugins did not activate Failed plugins (1): webserver (required) @@ -806,10 +807,10 @@ describe('auditStartupEntries', () => { at Server.listen (node:net:1:2) Plugins waiting for services (3): - Plugin Missing services - connection (required) webRuntime - web-runtime webServer - unknown unknown" + Plugin Missing services + web-runtime (required) webServer + connection (required) webStartup + unknown unknown" `) expect(warn).not.toHaveBeenCalled() }) @@ -1265,61 +1266,3 @@ describe('boot', () => { }) }) - -describe('addHarnessSourceSection', () => { - const SOURCE_ROOT = `${sep}opt${sep}harness-src` - const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.` - - it('distinguishes the source path from the current workdir after reusable instructions', async () => { - const ctx = new Context() - try { - await ctx.plugin(SystemPrompt, { personaPrefix: 'You are a coding agent.' }) - ctx.systemPrompt.section({ - name: 'tools:sdk', order: ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), text: 'Reusable tool SDK.', - }) - const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT) - expect(dispose).toBeTypeOf('function') - const systemPrompt = ctx.get('systemPrompt')! - const rendered = renderPrompt(await systemPrompt.assemble()) - expect(rendered).toContain(EXPECTED) - // The >= 0 guards keep a drifted opener/persona string from a false pass - // through `-1 < n`. - const identityAt = rendered.indexOf('You are an AI agent powered by DeepSeek Harness.') - const sourceAt = rendered.indexOf(EXPECTED) - const personaAt = rendered.indexOf('You are a coding agent.') - expect(identityAt).toBeGreaterThanOrEqual(0) - expect(personaAt).toBeGreaterThanOrEqual(0) - const sdkAt = rendered.indexOf('Reusable tool SDK.') - expect(personaAt).toBeGreaterThan(identityAt) - expect(sdkAt).toBeGreaterThan(personaAt) - expect(sdkAt).toBeLessThan(sourceAt) - } finally { - await ctx.fiber.dispose() - } - }) - - it('is a no-op returning undefined when no systemPrompt service is mounted', async () => { - const ctx = new Context() - try { - expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined() - } finally { - await ctx.fiber.dispose() - } - }) - - it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => { - const ctx = new Context() - try { - await ctx.plugin(SystemPrompt, {}) - const systemPrompt = ctx.get('systemPrompt')! - const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)! - const present = await systemPrompt.assemble() - expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true) - dispose() - const gone = await systemPrompt.assemble() - expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false) - } finally { - await ctx.fiber.dispose() - } - }) -}) diff --git a/packages/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index e59c38f2ba..9c3e57c809 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../../../vendor/group" }, - { - "path": "../../core/system-prompt" - }, { "path": "../../util/launch-environment" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index c6ea6cb37a..7d8f6d2b37 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/config-editor/locale/en.json b/packages/boot/config-editor/locale/en.json new file mode 100644 index 0000000000..ac0e222f08 --- /dev/null +++ b/packages/boot/config-editor/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "config-editor" + } +} diff --git a/packages/boot/config-editor/locale/zh.json b/packages/boot/config-editor/locale/zh.json new file mode 100644 index 0000000000..11a55507d9 --- /dev/null +++ b/packages/boot/config-editor/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "插件配置编辑", + "description": "保存插件配置,并将变更应用到当前运行配置。" + } +} diff --git a/packages/boot/config-editor/package.json b/packages/boot/config-editor/package.json index 346d9d4442..9d5e4dd44e 100644 --- a/packages/boot/config-editor/package.json +++ b/packages/boot/config-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-config-editor", "description": "Persist plugin configuration through profile patches and Loader reconciliation", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -19,9 +19,11 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.d.ts" ], diff --git a/packages/boot/hmr/README.i18n.yaml b/packages/boot/hmr/README.i18n.yaml index 4f1eef45bf..0c7fcc7a1f 100644 --- a/packages/boot/hmr/README.i18n.yaml +++ b/packages/boot/hmr/README.i18n.yaml @@ -21,8 +21,8 @@ en: 45ee194ad781d603 zh: b3d7fcd8ed73717c /deepseek-ai-dsh-hmr/understand-the-implementation: - en: 227c3c99c3c4b597 - zh: 853b656340cb8bce + en: 8c19121bdb5176c1 + zh: 8a01a60ecca6175c /deepseek-ai-dsh-hmr/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/boot/hmr/README.md b/packages/boot/hmr/README.md index 513abc07b2..97ee67c8e9 100644 --- a/packages/boot/hmr/README.md +++ b/packages/boot/hmr/README.md @@ -54,7 +54,7 @@ Chokidar options, including polling, retain their existing meaning. Exact config
Implementation internals — click to expand -`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place. +`watchConfig()` registers an awaited configuration handler and watches only its target path and required parent directories; sibling files, including temporary writer locks, are excluded before native watcher creation. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place. App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. Configuration watchers start outside the active transaction context so later notifications can enter the queue. diff --git a/packages/boot/hmr/README.zh.md b/packages/boot/hmr/README.zh.md index ba99733ccf..a7a9f84b1b 100644 --- a/packages/boot/hmr/README.zh.md +++ b/packages/boot/hmr/README.zh.md @@ -54,7 +54,7 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
实现细节——点击展开 -`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 Include 刷新和 profile 重载都通过普通的 Loader 条目更新到达插件;仅 volatile 变化由 Loader 就地提交。 +`watchConfig()` 注册会被等待的配置处理器,仅监听目标路径及必要的父目录;同级文件(包括临时写锁)在创建原生 watcher 前即被排除。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 Include 刷新和 profile 重载都通过普通的 Loader 条目更新到达插件;仅 volatile 变化由 Loader 就地提交。 App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。 diff --git a/packages/boot/hmr/locale/en.json b/packages/boot/hmr/locale/en.json new file mode 100644 index 0000000000..47f242be8c --- /dev/null +++ b/packages/boot/hmr/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "hmr" + } +} diff --git a/packages/boot/hmr/locale/zh.json b/packages/boot/hmr/locale/zh.json new file mode 100644 index 0000000000..f216cf3442 --- /dev/null +++ b/packages/boot/hmr/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "插件热重载", + "description": "在应用运行期间重新加载插件代码和配置。" + } +} diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json index d957e864ba..5c86376b65 100644 --- a/packages/boot/hmr/package.json +++ b/packages/boot/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hmr", "description": "Coordinated module and profile configuration hot reload", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -19,9 +19,11 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.d.ts" ], @@ -37,8 +39,8 @@ }, "dependencies": { "@babel/code-frame": "^7.29.0", - "chokidar": "^4.0.3", - "node-addon-require-builtin": "^0.1.6", + "chokidar": "5.0.0", + "node-addon-require-builtin": "^0.1.9", "picomatch": "^4.0.7", "@deepseek-ai/schemastery": "workspace:~" }, diff --git a/packages/boot/hmr/src/watch-config.ts b/packages/boot/hmr/src/watch-config.ts index 6c62610dec..41815b5d8d 100644 --- a/packages/boot/hmr/src/watch-config.ts +++ b/packages/boot/hmr/src/watch-config.ts @@ -1,5 +1,5 @@ /** Exact-path watching for live profile patch files outside Cordis module roots. */ -import { dirname, relative, resolve } from 'node:path' +import { dirname, relative, resolve, sep } from 'node:path' import { realpath, stat } from 'node:fs/promises' import { watch, type ChokidarOptions } from 'chokidar' import type { Context } from '@deepseek-ai/cordis' @@ -25,7 +25,8 @@ async function findWatchRoot(filename: string): Promise<{ filename: string; root } /** - * Watch one patch path, including missing parents, and serialize refresh callbacks. + * Watch one patch path and its parent chain without subscribing to sibling files or directories. + * Missing parents are observed as they appear; refresh callbacks run serially. * @param ctx Context that owns watcher disposal and receives refresh failures. * @param filename Absolute patch-file path. * @param options Deployment watcher options; configuration watches enable write stabilization by default. @@ -46,6 +47,10 @@ export async function watchConfig( const watcher = watch(target.root, { // Stabilized events bypass Chokidar's lossy 50 ms change-event throttle. awaitWriteFinish: true, ...watchOptions, depth: target.depth, ignoreInitial: false, + ignored: (path) => { + const remainder = relative(resolve(path), target.filename) + return remainder === '..' || remainder.startsWith(`..${sep}`) + }, }) paths.add(target.filename) const state = { dirty: false } diff --git a/packages/boot/hmr/tests/watch-config.spec.ts b/packages/boot/hmr/tests/watch-config.spec.ts index 7d0caf035b..e187d20a07 100644 --- a/packages/boot/hmr/tests/watch-config.spec.ts +++ b/packages/boot/hmr/tests/watch-config.spec.ts @@ -10,10 +10,13 @@ import { watchConfig } from '../src/watch-config.ts' import Hmr from '../src/index.ts' import Loader from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { FSWatcher, type ChokidarOptions } from 'chokidar' -const configWatch = vi.hoisted(() => ({ create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined })) +const configWatch = vi.hoisted(() => ({ + create: undefined as ((paths: string | string[], options?: ChokidarOptions) => FSWatcher) | undefined, +})) vi.mock('node:fs', async (importOriginal) => { const native = await importOriginal() return { ...native, realpathSync: vi.fn(native.realpathSync) } @@ -25,7 +28,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { vi.mock('chokidar', async (importOriginal) => { const native = await importOriginal() return { ...native, watch: (paths: string | string[], options?: ChokidarOptions) => - configWatch.create === undefined ? native.watch(paths, options) : configWatch.create(options) } + configWatch.create === undefined ? native.watch(paths, options) : configWatch.create(paths, options) } }) /** Every per-test tree root, removed once the booted watcher has been disposed. */ @@ -80,6 +83,42 @@ describe('HMR exact config paths', () => { for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + it.for([false, true])('excludes siblings while observing config replacement through an alias (missing parents: %s)', async (missingParents, test) => { + const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-scope-')) + onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) + const target = join(root, 'target') + const dir = join(root, 'alias') + mkdirSync(target) + symlinkSync(target, dir, process.platform === 'win32' ? 'junction' : 'dir') + const filename = join(dir, ...(missingParents ? ['later', 'nested'] : []), 'package.json') + writeFileSync(join(dir, 'package.json.lock'), 'held') + mkdirSync(join(dir, 'unrelated')) + writeFileSync(join(dir, 'unrelated', 'config.yml'), 'ignored') + if (!missingParents) writeFileSync(filename, 'initial') + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + let watcher!: FSWatcher + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + configWatch.create = (paths, options) => { + watcher = new FSWatcher(options) + return watcher.add(paths) + } + const observed: string[] = [] + await watchConfig(ctx, filename, {}, () => { observed.push(readFileSync(filename, 'utf8')) }) + const watchedEntries = () => Object.values(watcher.getWatched()).flat() + expect(watchedEntries()).not.toContain('package.json.lock') + expect(watchedEntries()).not.toContain('unrelated') + ensureNativeWatchLive(dir) + await writeFileAtomic(filename, 'created', { mode: 0o600 }) + await eventually(() => observed.includes('created'), 'created config was not observed', test.task.timeout) + await writeFileAtomic(filename, 'replaced', { mode: 0o600 }) + await eventually(() => observed.includes('replaced'), 'replaced config was not observed', test.task.timeout) + expect(watchedEntries()).toContain('package.json') + expect(watchedEntries()).not.toContain('package.json.lock') + expect(watchedEntries()).not.toContain('unrelated') + }) + it.each(['relative', 'absolute'])('observes %s module roots through a filesystem alias', { timeout: 30_000 }, async (rootKind) => { const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) const alias = `${target}-alias` @@ -252,7 +291,7 @@ describe('HMR exact config paths', () => { let watcher!: FSWatcher const previousFactory = configWatch.create onTestFinished(() => { configWatch.create = previousFactory }) - configWatch.create = (options) => { + configWatch.create = (_paths, options) => { watcher = new FSWatcher(options) // Use Chokidar's real normalization with deterministic event delivery. Reflect.set(watcher, '_readyEmitted', true) diff --git a/packages/boot/plugin-manager/locale/en.json b/packages/boot/plugin-manager/locale/en.json new file mode 100644 index 0000000000..094967d50f --- /dev/null +++ b/packages/boot/plugin-manager/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "plugin-manager" + } +} diff --git a/packages/boot/plugin-manager/locale/tools/en.json b/packages/boot/plugin-manager/locale/tools/en.json new file mode 100644 index 0000000000..2fdfce031d --- /dev/null +++ b/packages/boot/plugin-manager/locale/tools/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "plugin-manager/tools" + } +} diff --git a/packages/boot/plugin-manager/locale/tools/zh.json b/packages/boot/plugin-manager/locale/tools/zh.json new file mode 100644 index 0000000000..ab001f0ccb --- /dev/null +++ b/packages/boot/plugin-manager/locale/tools/zh.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "插件管理工具" + } +} diff --git a/packages/boot/plugin-manager/locale/zh.json b/packages/boot/plugin-manager/locale/zh.json new file mode 100644 index 0000000000..e5ffb5869b --- /dev/null +++ b/packages/boot/plugin-manager/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "插件管理服务", + "description": "管理当前运行配置中的插件与插件包,支持安装、移除和启停。" + } +} diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index c6fc6af78a..756d5adb93 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-manager", "description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -43,9 +43,13 @@ "./tools": { "types": "./lib/types/tools.d.ts", "default": "./lib/types/tools.js" - } + }, + "./tools/locale/*.json": "./locale/tools/*.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", + "locale/tools/*.json", "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", @@ -61,7 +65,7 @@ "@deepseek-ai/schemastery": "workspace:~", "execa": "^10.0.1", "yaml": "^2.9.0", - "zod": "^4.4.3", + "zod": "4.4.3", "@deepseek-ai/dsh-util-values": "workspace:*" }, "peerDependencies": { diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index bc7da82dfe..dab620bff1 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -1,10 +1,11 @@ +import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' /** Agent controls use the same manager methods as the Web and bound inventory reads. */ import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import SandboxPolicy, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import SessionProjections from '@deepseek-ai/dsh-session-projection' import ApprovalService, { type ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' diff --git a/packages/browser-use/browser-use/package.json b/packages/browser-use/browser-use/package.json index 8178f5d01d..88f9365e81 100644 --- a/packages/browser-use/browser-use/package.json +++ b/packages/browser-use/browser-use/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-browser-use", "description": "Exclusive named browser-use provider registration", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index d1d12860c2..525414a75b 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index fb913cd92f..9f87051c49 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -328,7 +328,7 @@ name: '@deepseek-ai/dsh-plan-mode' config: section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index be152900d3..fa80fcfb55 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index cf2dc9f38f..1eea38a9f0 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index ca0a12fc89..fb39b99573 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4e53164159b5ce41 zh: 89582dc0294d0776 /deepseek-ai-dsh-sdk-app/use-this-package: - en: 465be8e510a86913 - zh: 07e2e1efba1d108b + en: 63573087d1db9aff + zh: 638612ac3f0da2c0 /deepseek-ai-dsh-sdk-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index e2615fc435..a149ec1254 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -33,7 +33,7 @@ The startup provider binds stdin EOF to the launcher's bounded successful shutdo The SDK uses the base `read`, `write`, and `edit` defaults. To add `str_replace_editor`, use the explicit insertion patch in the [base configuration guide](../base/README.md#use-this-package). The standalone `sdk-minimal` profile owns its separate tool selection. -The packaged Python runtime enables the [Office runtime query and skills](../../skill/tool-workspace-dependencies/README.md#use-this-package) by default. `DSH_PRIMARY_RUNTIME` overrides its bundled payload; an empty value disables both rows. Source launches without a carrier default remain opt-in. The tool reads the payload in place. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence over bundled skills. See the [runtime package](../../../python/sdk-runtime/README.md) for packaging and configuration. +The Python runtime exposes explicitly downloaded authoring resources and Office sidecars to this profile. `DSH_PRIMARY_RUNTIME` overrides the authoring payload; an empty value disables its query. Office skills can use either resource independently. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence. See the [runtime package](../../../python/sdk-runtime/README.md) for downloads and configuration. ----- diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index 610f4bef5c..f244072935 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -33,7 +33,7 @@ kind: "package-bundle" SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_replace_editor`,请使用 [base 配置指南](../base/README.zh.md#use-this-package)中的显式插入 patch。独立的 `sdk-minimal` profile 自行决定其工具选择。 -打包的 Python runtime 默认启用 [Office 运行时查询与 skills](../../skill/tool-workspace-dependencies/README.zh.md#use-this-package)。`DSH_PRIMARY_RUNTIME` 覆盖随包 payload;空值禁用两行。没有载体默认路径的源码启动仍需显式启用。工具原位读取 payload。profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先于随包 skills。打包与配置见 [runtime 包](../../../python/sdk-runtime/README.zh.md)。 +Python 运行时向本 profile 提供显式下载的创作资源和 Office sidecar。`DSH_PRIMARY_RUNTIME` 覆盖创作载荷;空值禁用其查询。Office skills 可独立使用任一种资源。Profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先。下载与配置见[运行时包](../../../python/sdk-runtime/README.zh.md)。 ----- diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 1c0beaae95..004a81ce9b 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -29,7 +29,8 @@ - id: skill-office name: '@deepseek-ai/dsh-skill-office' - disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)" + disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) && !process.env.DSH_BUNDLED_OFFICE_CLI" config: - assetRoot: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" - node: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" + assetRoot: !!js "process.env.DSH_BUNDLED_OFFICE_SKILLS ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" + cli: !!js "process.env.DSH_BUNDLED_OFFICE_CLI === '' ? false : process.env.DSH_BUNDLED_OFFICE_CLI" + node: !!js "process.env.DSH_BUNDLED_OFFICE_NODE ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index fdfc1d2ad3..389582f50c 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index df59eaae4d..e952695405 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 0c5a6c4a2e..2a9948d8dd 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -9,8 +9,8 @@ en: a85684740ecd278a zh: 7c54099b9edccf01 /deepseek-ai-dsh-web-app/summary: - en: fd97ee0c14fb5378 - zh: 0e1ccba47bc408d9 + en: 0a2a9876194543e5 + zh: ff472776a44ae260 /deepseek-ai-dsh-web-app/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,59 +18,56 @@ en: 3bd37b85139d2fa7 zh: ba86c807eb83866e /deepseek-ai-dsh-web-app/use-this-package/starting-the-web-gui: - en: b7ab01cfe9fed40f - zh: 23703ab7727c3ab5 + en: e3cac911f080d8ef + zh: 60749e9f5c39c1bc /deepseek-ai-dsh-web-app/use-this-package/configuration: - en: a9ee366c5af68a6c - zh: 2711d7a3926e1101 + en: ad3bf2a8a2a8a99e + zh: cf24ef814a0352b9 /deepseek-ai-dsh-web-app/use-this-package/listening-trust-and-public-deployments: - en: a79add86cb73d966 - zh: 78b58259f98ed063 + en: e6ddea1d2fc40501 + zh: ff08b6fa3fc24136 /deepseek-ai-dsh-web-app/use-this-package/running-over-ssh: - en: 45db677dcc91c5c9 - zh: 75b765f7f1e15035 + en: df205a26b6d49168 + zh: e46ed7cf9e06248a /deepseek-ai-dsh-web-app/use-this-package/per-session-agent-setup: en: ccd6fc264b51e44b zh: 3265c68477a89ec9 /deepseek-ai-dsh-web-app/understand-the-implementation: - en: fb819d10930089a8 - zh: f2dee0ff170a4d41 + en: 323742a00cfe67b2 + zh: eff5245a82fc16e7 /deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics: - en: 23ddb4d33e79c0a1 - zh: dc9a458b9492b49b + en: 8a2554b3d17daa1f + zh: 66f18946791cabb6 /deepseek-ai-dsh-web-app/understand-the-implementation/advertised-application-url: - en: ca977d2b2bcf72c5 - zh: 2b81b377c133c1a8 + en: 0b22e03e28d41420 + zh: fd45a97dd4336c25 /deepseek-ai-dsh-web-app/understand-the-implementation/readiness: en: 973128fd346869df zh: 54e6792f9f82a192 -/deepseek-ai-dsh-web-app/understand-the-implementation/lan-trust-sampling: - en: b5ed8ff0056ca9da - zh: fdc1fa4f14cb00b3 /deepseek-ai-dsh-web-app/understand-the-implementation/source-map: - en: e41444bed021a121 - zh: 44088a90df50d5b4 + en: 58800b6f2142c34f + zh: 10bf512b16db1cb8 /deepseek-ai-dsh-web-app/further-exploration: en: f81694f7424e2988 zh: 6643f9227039e228 /deepseek-ai-dsh-web-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context: - en: e6febc53efe0f51d - zh: 9177816a8678d65d -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/what-the-model-sees: - en: 583592075e04687d - zh: 9defddfd77bab0f7 -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/token-effect: - en: 908fc84d6849a830 - zh: 56fe5e418bab60ba -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/kv-cache-effect: - en: 1d7d87f54b20b563 - zh: d150a51615d278e8 +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable: + en: 623bf8a0bf05599d + zh: 398ded2e7b20c1ea +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable/what-the-model-sees: + en: ebaddc153ad7a835 + zh: 21aa60f73d14f26c +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable/token-effect: + en: 449fbfa3986f98f0 + zh: a7d66333594ab2dd +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable/kv-cache-effect: + en: 37542a8b81a744f5 + zh: c0048aaadecae296 /deepseek-ai-dsh-web-app/known-limitations-and-deferred-work: - en: d6de1d07249ef3b0 - zh: 49d01329256e2eb3 + en: 635c1336e2f4e821 + zh: caabdecb02e2df14 /deepseek-ai-dsh-web-app/known-limitations-and-deferred-work/dev-note: en: 3f81b4f3beb74a0d zh: 2e377ca40822d093 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index fc4584fbad..fb7a19c963 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -13,7 +13,7 @@ Desktop analytics schedules partial batches every 30 seconds, with a 15-second e ## Summary -Run `dsh --profile web` for browser chat, model and settings management, and session history, using the same model access, tools, and safety defaults as other dsh surfaces. Startup prints a tokenized URL and normally opens the default browser; SSH sessions and `--no-open` require manual opening. You can change the port and allow extra hosts, but cannot bind all network interfaces. Remote access supports an advertised public HTTP(S) URL behind a prefix-stripping proxy. For one-shot command-line tasks, use `dsh-headless`. +Run `dsh --profile web` for browser chat, model and settings management, and session history, with the same model access, tools, and safety defaults as other dsh surfaces. Startup prints a tokenized URL and normally opens the default browser; SSH sessions and `--no-open` require manual opening. You can change the port, allow extra authorities, bind one concrete local IP, and serve HTTPS from a certificate you supply; wildcard addresses are rejected. Remote access uses an advertised HTTP(S) URL behind a prefix-stripping proxy, listener TLS, or plain HTTP on a non-loopback bind. Use the headless profile for one-shot command-line tasks. ## Table of Contents @@ -40,36 +40,49 @@ dsh --profile web --no-open --port 8080 After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the same directory without the token. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself. -**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#endpoint-and-wire-format) uses the Messages API. +**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V4.1-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#endpoint-and-wire-format) uses the Messages API. Saved model selections override the composition default. The settings card accepts a Messages-compatible API address and a credential reference. ### Configuration -`--host` and `--port` configure the listener; `--public-url` names the advertised public HTTP(S) root the GUI is reached at behind a prefix-stripping proxy, and `--trusted-host` adds further accepted authorities. All are described under [Listening, trust, and public deployments](#public-deployments): +`--host` and `--port` configure the listener; `--tls-cert` and `--tls-key` make it serve HTTPS; `--public-url` names the advertised public HTTP(S) root the GUI is reached at behind a prefix-stripping proxy, and `--trusted-host` adds further accepted authorities. All are described under [Listening, trust, and public deployments](#public-deployments), and the `tls` field those flags set is documented with the [carrier's config](../../../docs/subsystems/web-server.md#config): | Field | Default | Meaning | |---|---|---| | `openBrowser` | `true` | Open the default browser after startup; SSH launches suppress it | | `printUrl` | `true` | Print the `dsh web:` URL line at startup | -| `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands | -| `publicUrl` | Unset | Advertised HTTP(S) application root; otherwise announce the listener's loopback URL | -| `trustedHosts` | `[]` | Extra hosts allowed to reach the GUI from the network | +| `surfaceContext` | `true` | Expose `DSH_WEB_URL` to the agent's shell commands | +| `publicUrl` | Unset | Advertised HTTP(S) application root; otherwise announce the bind-address URL | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) lists this runtime plugin's accepted fields and their JSDoc. The shipped composition inserts the `schedule` service row and the `ui-schedule` task page row, while the clock reading and the four reminder tools belong to the `standard`, `cordis`, and `ptc` presets. The `tool-subagent` and `tool-subagent-fork` rows in those presets deny the four tools, so a delegated child's scope never lists them. ### Listening, trust, and public deployments -By default the GUI listens on loopback and accepts connections from this machine only; repeatable `--trusted-host` adds the authorities its Host/Origin fence accepts, so a remote browser reaches it behind a prefix-stripping proxy or through a port-forwarding client that presents a trusted hostname. +By default the GUI listens on loopback and accepts connections from this machine only. Only `--host` changes the listener: it accepts one concrete local IPv4 or IPv6 address, never a wildcard. A non-loopback listener without TLS serves plain HTTP and warns at startup, even when an HTTPS `--public-url` fronts it. The Host/Origin fence accepts the bind IP itself; other non-loopback authorities require `--trusted-host`, so a remote browser reaches the GUI behind a prefix-stripping proxy or through a port-forwarding client that presents a trusted hostname. The launch-token exchange and signed session cookie authenticate every API method and WebSocket stream. -`--public-url` advertises the HTTP(S) root browsers use — the printed and opened startup URL, `DSH_WEB_URL`, and the web-surface orientation. Advertisement grants no trust: the browser-visible authority must also be named with `--trusted-host`. The flag configures no listener, routing, or cookie scope, because the proxy owns the external leg: [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what such a deployment must provide. +Pass `--tls-cert` and `--tls-key` together to serve HTTPS from the listener instead of plain HTTP. Each names a file resolved against the process working directory: the certificate file holds the full chain and the key file an unencrypted PEM private key. The flags are one setting — supplying only one is a usage error, and supplying neither keeps the plain-HTTP default. Startup fails when a file is missing, unreadable, empty, or not a valid certificate-and-key pair; it never falls back to HTTP. Nothing re-reads the material while the listener runs, so serving a different certificate needs a listener reload or a process restart. DSH issues, renews, and watches nothing: there is no ACME client, no self-signed fallback, and no redirect listener. The browser applies its own trust store and host-name matching, so a certificate the browser does not trust, or one whose subject alternative names miss the authority you open, fails before the page loads. The default port stays 3080, and `--tls-cert`/`--tls-key` do not follow `--public-url`: the listener's certificate covers the authority browsers dial. TLS changes nothing about authority: Connection still authenticates every request and still checks Host/Origin, and the session cookie it mints is `Secure` whenever the receiving listener serves HTTPS. -The printed URL contains a process credential; share it only with intended users, and `printUrl: false` suppresses the line with or without `--public-url`. +A container can bind its Pod address and advertise the ingress that fronts it: + +```sh +dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example +``` + +`hostname -i` may list several addresses; `--host` takes one, so the command selects the first. A zone ID such as `fe80::1%eth0` cannot appear in a URL, so the advertised root drops a redundant loopback zone while a non-loopback zone requires an explicit `--public-url`. + +Without `--public-url`, mapped IPv6 loopback binds such as `::ffff:127.0.0.1` advertise their IPv4 form, `127.0.0.1`, so the browser recognizes a local, trustworthy origin. The listener keeps the configured bind address. A dotted-quad IPv6 tail names the address's own low 32 bits, exactly as `listen` reads it, so `::0.0.0.1` is IPv6 loopback and advertises `[::1]`, while `::127.0.0.1` is a non-loopback address, not IPv4 loopback. + +`--public-url` advertises the HTTP(S) root browsers use — the printed and opened startup URL, `DSH_WEB_URL`, and the Web GUI URL in the agent's runtime context. Advertisement grants no trust: the browser-visible authority must also be named with `--trusted-host`. The flag configures no listener, routing, or cookie scope, because the proxy owns the external leg: [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what such a deployment must provide. + +Neither the advertised URL nor the browser-trust fence protects the port itself, so restrict the port to the trusted proxy or network. + +The printed URL contains a process credential; share it only with intended users. `printUrl: false` suppresses the line with or without `--public-url`, and the credential never appears in the model context or in `DSH_WEB_URL`. ### Running over SSH -When you launch `dsh --profile web` over SSH, the URL line still prints but the browser is not opened for you: the SSH client or editor owns the local forwarding address. Without an advertised root the printed URL names the remote host's loopback endpoint, which you reach through your forwarding address. With `--public-url` the printed URL is the authenticated advertised root; the browser handoff stays suppressed, because opening a browser on the remote host cannot reach your screen. +When you launch `dsh --profile web` over SSH, the URL line still prints but the browser is not opened for you: the SSH client or editor owns the local forwarding address. Without an advertised root the printed URL names the remote host's bind-address endpoint — loopback for a loopback bind — which you reach through your forwarding address. With `--public-url` the printed URL is the authenticated advertised root; the browser handoff stays suppressed, because opening a browser on the remote host cannot reach your screen. ### Per-session agent setup @@ -83,37 +96,32 @@ Each browser session selects a shipped preset (`standard` by default). The Agent
Implementation internals — click to expand -The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache. +The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache. ### Patch semantics -A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver` and `web-runtime` rows inject the `webStartup` provider and read their invocation values directly; the `connection` row instead reads the bind-dependent `webRuntime` values the web-runtime row publishes, which are that provider's authorities plus the LAN literals of an all-interfaces bind. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. +A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver`, `web-runtime`, and `connection` rows inject the `webStartup` provider and read their invocation values directly; Connection composes its accepted authorities from `ctx.webStartup.trustedHosts`, and the Host/Origin fence accepts the listener's bind IP independently of that list. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. ### Advertised application URL -Startup display and browser handoff receive the advertised root with its launch token; the web-surface prompt and `DSH_WEB_URL` receive it clean. The root itself is defined under [Listening, trust, and public deployments](#public-deployments). +Startup display and browser handoff receive the advertised root with its launch token; the agent's runtime context and `DSH_WEB_URL` receive it clean. The root itself is defined under [Listening, trust, and public deployments](#public-deployments). ### Readiness The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, the required-startup audit passes, and Connection authentication is available — or immediately in a hand-built tree without a Loader. Client combo JavaScript and source maps remain unmaterialized at this point. Optional plugin failures do not suppress readiness; a required startup failure or a tree disposed mid-boot announces nothing. -### LAN trust sampling - -`resolveLanTrust` samples the network once at boot: a loopback bind (`127.0.0.1`) derives no LAN addresses, while an all-interfaces bind adds every non-internal IPv4 literal. The derived literals plus the explicit `--trusted-host` authorities form the `/api` browser-trust fence, and the printed LAN URL always matches that fence. - ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, LAN trust sampling, prompt sections, bash variable, URL line, browser handoff | +| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, bash variable, URL line, browser handoff | | [`src/public-url.ts`](src/public-url.ts) | Advertised-root validation and trailing-slash normalization; a leaf module for local imports, not package API | -| [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`, `--help` | +| [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--tls-cert`, `--tls-key`, `--public-url`, `--trusted-host`, `--no-open`, `--help` | | [`cordis.patch.yml`](cordis.patch.yml) | The web patch: restated base values, web host rows, browser roster, preset registry | | [`presets/`](presets) | One `@deepseek-ai/dsh-agent-preset` declaration per shipped preset (`standard`, `ptc`, `minimal`, `cordis`), each its own patch file | -| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist resolution, fallback seat, prompt sections, readiness, advertised URL publication | +| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist index anchoring, advertised-URL publication and warnings, bash variable, readiness publication | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree | | [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | Advertised-root parsing and normalization | -| [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN-trust sampling | | [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | Default-browser handoff after the page is reachable |
@@ -136,19 +144,19 @@ Read these pages when you want to go deeper into the shared core, the browser re ## Model Experience -### Harness-source and Web-surface context +### Web runtime variable #### What the model sees -When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (first-party order 10100, after reusable instructions) orients the model to the GUI: the advertised application URL (defined under Listening, trust, and public deployments above), the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered. +When `surfaceContext` is true, `DSH_WEB_URL` appears in the managed bash environment with its description, resolved per invocation as the advertised application URL defined under Listening, trust, and public deployments above. When it is false, the variable is not registered. The GUI orientation, the implementation checkout, and the current GUI URL come from the [`dsh-harness-dev-context`](../../context/harness-dev-context/README.md) row this bundle mounts. #### Token effect -One source line and one prompt paragraph per session plus two managed-environment variable lines; constant per process. +Two managed-environment variable lines; constant per process. #### KV Cache effect -Source and Web sections follow first-party reusable instructions. Different checkout paths or application URLs leave that preceding prefix unchanged when tools and configuration match; provider cache reuse is not guaranteed. +None; the plugin contributes no system-prompt text. ## Known Limitations and Deferred Work @@ -158,12 +166,12 @@ Source and Web sections follow first-party reusable instructions. Different chec These limits tell you what to expect in unusual setups — a source checkout, SSH sessions, or strict networks. They are current package constraints, not a general browser comparison or a task backlog. - **The frontend must be built** — a source checkout needs `pnpm run build` first; startup stops with a build hint when the dist is missing, and there is no source-serving fallback. -- **The listener has no TLS** — protect the external leg with a TLS-terminating proxy; an HTTP advertised root sends credentials without encryption. -- **LAN addresses are sampled once at startup** — interface changes after boot are not re-advertised; the printed LAN URL always matches what was sampled. +- **TLS is opt-in and unmanaged** — the listener serves HTTPS only when `--tls-cert` and `--tls-key` name a full chain and its unencrypted key; DSH never obtains, renews, or watches certificates, and a changed file needs a listener reload or a process restart. The browser must trust that certificate, its names must cover the authority you open, and the certificate is independent of `--public-url`. +- **Without TLS the external leg is plain HTTP** — protect it with a TLS-terminating proxy, and do not send the launch URL over a network you do not trust. - **Only the handoff start is observable** — the GUI reports that the browser was asked to open, not that it actually opened; a later browser exit is never reported, and the printed URL is your manual fallback. -- **SSH sessions keep the URL but skip the browser handoff** — without an advertised root the printed URL names the remote host's loopback endpoint; the SSH client or editor must expose and open the local forwarded address. +- **SSH sessions keep the URL but skip the browser handoff** — without an advertised root the printed URL names the remote host's bind-address endpoint (loopback for a loopback bind); the SSH client or editor must expose and open the local forwarded address. - **`BROWSER` overrides only come from the environment** — a discovered `.env` cannot set `BROWSER`; only an inherited value can choose the executable for the automatic handoff. -- **Binding all network interfaces is not supported** — `--host 0.0.0.0` is rejected at startup for safety; use the default loopback host. +- **Binding all network interfaces is not supported** — a wildcard `--host`, including the IPv4-mapped forms Linux reads as IPv4 any, is rejected at startup for safety; bind one concrete IPv4 or IPv6 address of a local interface instead. ### Dev Note diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 3b7774bfd2..40930ff56b 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -13,7 +13,7 @@ kind: "package-bundle" ## 概述 -运行 `dsh --profile web`,获得浏览器内的聊天、模型与设置管理以及会话历史,并与其他 dsh 表层共用同一套模型访问、工具与安全默认值。启动时会打印带 token 的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 需要手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。跨机访问支持在剥离前缀的代理之后公告一个公开的 HTTP(S) URL。一次性的命令行任务应使用 `dsh-headless`。 +运行 `dsh --profile web`,获得浏览器内的聊天、模型与设置管理以及会话历史,并与其他 dsh 表层共用同一套模型访问、工具与安全默认值。启动时会打印带 token 的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 需要手动打开。你可以更改端口、允许额外 authority、绑定一个具体的本机 IP,并用自己提供的证书直接提供 HTTPS;通配地址会被拒绝。跨机访问使用剥离前缀代理后公告的 HTTP(S) URL、监听器 TLS,或非 loopback 绑定上的明文 HTTP。一次性的命令行任务应使用 headless 配置。 ## 目录 @@ -40,36 +40,49 @@ dsh --profile web --no-open --port 8080 启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到不含认证参数的同一目录。页面加载且你可以与 agent 对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。 -**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#endpoint-and-wire-format)使用 Messages API。 +**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V4.1-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#endpoint-and-wire-format)使用 Messages API。 已保存的模型选择覆盖组合默认值。设置卡接受兼容 Messages 的 API 地址与凭据引用。 ### 配置 -`--host` 与 `--port` 配置监听器;`--public-url` 指定 GUI 在剥离前缀的代理之后对外公告的公开 HTTP(S) 根,`--trusted-host` 则添加更多被接受的 authority。两者都在[监听、信任与公开部署](#public-deployments)中说明: +`--host` 与 `--port` 配置监听器;`--tls-cert` 与 `--tls-key` 让它直接提供 HTTPS;`--public-url` 指定 GUI 在剥离前缀的代理之后对外公告的公开 HTTP(S) 根,`--trusted-host` 则添加更多被接受的 authority。这些都在[监听、信任与公开部署](#public-deployments)中说明,而这些 flag 设置的 `tls` 字段记录在[载体的配置](../../../docs/subsystems/web-server.zh.md#config)中: | 字段 | 默认值 | 含义 | |---|---|---| | `openBrowser` | `true` | 启动后用默认浏览器打开;SSH 启动会抑制它 | | `printUrl` | `true` | 启动时打印 `dsh web:` URL 行 | -| `surfaceContext` | `true` | 给 agent 提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 | -| `publicUrl` | 未设置 | 对外公告的 HTTP(S) 应用根;否则公告监听器的 loopback URL | -| `trustedHosts` | `[]` | 允许从网络访问 GUI 的额外主机 | +| `surfaceContext` | `true` | 把 `DSH_WEB_URL` 暴露给 agent 的 shell 命令 | +| `publicUrl` | 未设置 | 对外公告的 HTTP(S) 应用根;否则公告绑定地址 URL | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)列出了本运行时插件接受的字段及其 JSDoc。随发行版交付的组合插入 `schedule` 服务行与 `ui-schedule` 任务页面行,时钟读数与四个提醒工具则属于 `standard`、`cordis` 与 `ptc` 三个 preset。这三个 preset 的 `tool-subagent` 与 `tool-subagent-fork` 两行都 deny 这四个工具,因此被委派子 agent 的作用域不会列出它们。 ### 监听、信任与公开部署 -默认情况下 GUI 只在 loopback 上监听,只接受本机的连接;可重复的 `--trusted-host` 会添加其 Host/Origin 栅栏接受的 authority,因此远端浏览器要么经由剥离前缀的代理访问,要么通过以可信主机名呈现的端口转发客户端访问。 +默认情况下 GUI 只监听 loopback,只接受本机的连接。只有 `--host` 会改变监听器:它接受一个具体的本机 IPv4 或 IPv6 地址,不接受通配地址。未启用 TLS 的非 loopback 监听器提供明文 HTTP,并在启动时警告,即使前置 HTTPS 的 `--public-url` 也不例外。Host/Origin 栅栏接受绑定 IP 本身;其他非 loopback authority 需要 `--trusted-host`,因此远端浏览器要么经由剥离前缀的代理访问 GUI,要么通过以可信主机名呈现的端口转发客户端访问。启动 token 交换与签名会话 cookie 认证每个 API 方法与 WebSocket 流。 -`--public-url` 公告浏览器使用的 HTTP(S) 根——打印与打开的启动 URL、`DSH_WEB_URL` 与 web 表层定位。公告不授予任何信任:浏览器可见的 authority 还必须用 `--trusted-host` 点名。该 flag 不配置监听器、路由或 cookie 作用域,因为外部链路归代理所有:[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了这样的部署必须提供什么。 +同时传入 `--tls-cert` 与 `--tls-key`,监听器便以 HTTPS 而非明文 HTTP 提供服务。两者各指定一个相对进程工作目录解析的文件:证书文件包含完整证书链,密钥文件为不带口令的 PEM 私钥。它们属于同一个设置——只提供一个会报用法错误,两个都不提供则保持明文 HTTP 默认值。文件缺失、不可读、为空,或不是有效的证书与密钥对时启动失败;绝不回退到 HTTP。监听器运行期间不会重新读取这些材料,因此更换证书需要重新加载监听器或重启进程。DSH 不签发、不续期也不监视任何证书:没有 ACME 客户端、没有自签名兜底,也没有重定向监听器。浏览器使用自己的信任库与主机名匹配,因此浏览器不信任的证书,或其 subject alternative name 未覆盖你所访问 authority 的证书,会在页面加载前失败。默认端口仍为 3080,且 `--tls-cert`/`--tls-key` 不跟随 `--public-url`:监听器的证书覆盖浏览器实际访问的 authority。TLS 不改变授权模型:Connection 仍认证每个请求并仍校验 Host/Origin,且只要接收请求的监听器提供 HTTPS,它所签发的会话 cookie 就带 `Secure`。 -打印的 URL 包含进程凭据,只应与预期用户分享;`printUrl: false` 无论是否配置 `--public-url` 都会抑制该行。 +容器可以绑定其 Pod 地址,并公告前置的 ingress: + +```sh +dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example +``` + +`hostname -i` 可能列出多个地址;`--host` 只接受一个,因此命令取第一个。`fe80::1%eth0` 这样的 zone ID 不能出现在 URL 中,因此公告根会剥离多余的 loopback zone,而非 loopback zone 则要求显式提供 `--public-url`。 + +未设置 `--public-url` 时,`::ffff:127.0.0.1` 这样的 mapped IPv6 回环绑定会公告其 IPv4 形式 `127.0.0.1`,让浏览器将其识别为本地可信来源。监听器仍使用配置的绑定地址。点分四段式的 IPv6 尾部表示该地址自身的低 32 位,与 `listen` 的读法完全一致:`::0.0.0.1` 属于 IPv6 回环并公告 `[::1]`,而 `::127.0.0.1` 是非回环地址,并非 IPv4 回环。 + +`--public-url` 公告浏览器使用的 HTTP(S) 根——打印与打开的启动 URL、`DSH_WEB_URL`,以及 agent 运行时上下文中的 Web GUI URL。公告不授予任何信任:浏览器可见的 authority 还必须用 `--trusted-host` 点名。该 flag 不配置监听器、路由或 cookie 作用域,因为外部链路归代理所有:[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了这样的部署必须提供什么。 + +无论是公告 URL 还是浏览器信任栅栏都不保护端口本身,因此请把端口限制在可信代理或网络内。 + +打印的 URL 包含进程凭据,只应与预期用户分享。`printUrl: false` 无论是否配置 `--public-url` 都会抑制该行,凭据也绝不会出现在模型上下文或 `DSH_WEB_URL` 中。 ### 通过 SSH 运行 -通过 SSH 启动 `dsh --profile web` 时,URL 行仍会打印,但不会为你打开浏览器:本地转发地址由 SSH 客户端或编辑器持有。没有公告根时,打印的 URL 指向远端宿主机 loopback 端点,你通过自己的转发地址访问它。配置了 `--public-url` 时,打印的 URL 是带认证的公告根;浏览器交接仍被抑制,因为在远端宿主机上打开浏览器无法到达你的屏幕。 +通过 SSH 启动 `dsh --profile web` 时,URL 行仍会打印,但不会为你打开浏览器:本地转发地址由 SSH 客户端或编辑器持有。没有公告根时,打印的 URL 指向远端宿主机的绑定地址端点——绑定 loopback 时即 loopback——你通过自己的转发地址访问它。配置了 `--public-url` 时,打印的 URL 是带认证的公告根;浏览器交接仍被抑制,因为在远端宿主机上打开浏览器无法到达你的屏幕。 ### 按会话的 agent 设置 @@ -83,37 +96,32 @@ dsh --profile web --no-open --port 8080
实现细节——点击展开 -此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 +此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 ### patch 语义 -patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver` 与 `web-runtime` 行注入 `webStartup` 提供方并直接读取本次调用的取值;`connection` 行则改为读取 web-runtime 行发布的、与绑定相关的 `webRuntime` 值,即该提供方的 authority 加上全接口绑定的 LAN 字面量。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 +patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver`、`web-runtime` 与 `connection` 行都注入 `webStartup` 提供方并直接读取本次调用的取值;Connection 的被接受 authority 全部来自 `ctx.webStartup.trustedHosts`,Host/Origin 栅栏则独立于该列表接受监听器的绑定 IP。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 ### 公告应用 URL -启动显示与浏览器交接接收附带启动 token 的公告根;web 表层提示词与 `DSH_WEB_URL` 接收不含凭据的形式。根本身由[监听、信任与公开部署](#public-deployments)定义。 +启动显示与浏览器交接接收附带启动 token 的公告根;agent 的运行时上下文与 `DSH_WEB_URL` 接收不含凭据的形式。根本身由[监听、信任与公开部署](#public-deployments)定义。 ### 就绪宣告 URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、通过 required 启动检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。此时 client combo JavaScript 和 source map 仍未物化。可选插件失败不会阻止就绪宣告;required 启动失败或启动中途被释放的树不会宣告任何内容。 -### LAN 信任采样 - -`resolveLanTrust` 在启动时只采样一次网络:loopback 绑定(`127.0.0.1`)不派生任何 LAN 地址,绑定所有网卡则会加入每个非 internal IPv4 字面量。派生字面量加上显式的 `--trusted-host` 权威标识组成 `/api` 浏览器信任栅栏,打印的 LAN URL 始终与该栅栏一致。 - ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、LAN 信任采样、提示词段落、bash 变量、URL 行、浏览器交接 | +| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、bash 变量、URL 行、浏览器交接 | | [`src/public-url.ts`](src/public-url.ts) | 公告根的校验与尾斜杠归一化;供本地导入的叶子模块,不属于包 API | -| [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--public-url`、`--trusted-host`、`--no-open`、`--help` | +| [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--tls-cert`、`--tls-key`、`--public-url`、`--trusted-host`、`--no-open`、`--help` | | [`cordis.patch.yml`](cordis.patch.yml) | Web patch:重述的基础值、Web 宿主行、浏览器名录、preset 注册表 | | [`presets/`](presets) | 每个随发行版交付的 preset(`standard`、`ptc`、`minimal`、`cordis`)各一条 `@deepseek-ai/dsh-agent-preset` 声明,各自一个补丁文件 | -| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist 解析、回退席位、提示词段落、就绪宣告、公告 URL 发布 | +| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist index 锚定、公告 URL 发布与警告、bash 变量、就绪宣告发布 | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 | | [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | 公告根的解析与归一化 | -| [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN 信任采样 | | [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | 页面可达后的默认浏览器交接 |
@@ -136,19 +144,19 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发 ## 模型体验 -### Harness 源码与 Web 表层上下文 +### Web 运行时变量 #### 模型看到什么 -当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(first-party 顺序 10100,位于可复用指令之后)则向模型说明 GUI:公告应用 URL(定义见上文「监听、信任与公开部署」)、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。 +当 `surfaceContext` 为 true 时,`DSH_WEB_URL` 连同描述出现在受管 bash 环境中,每次调用时解析为上文「监听、信任与公开部署」定义的公告应用 URL。为 false 时不注册该变量。GUI 定位、实现 checkout 与当前 GUI URL 由本 bundle 挂载的 [`dsh-harness-dev-context`](../../context/harness-dev-context/README.zh.md) 行提供。 #### Token 影响 -每个会话一行源码说明和一段提示词,外加两行受管环境变量;每个进程内保持恒定。 +两行受管环境变量;每个进程内保持恒定。 #### KV Cache 影响 -源码与 Web 段落位于第一方可复用指令之后。工具与配置一致时,不同 checkout 路径或应用 URL 不会改变前置前缀;不保证提供方复用缓存。 +无;该插件不贡献系统提示词文本。 ## 已知限制与延期工作 @@ -158,12 +166,12 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发 这些限制告诉你在不常见的环境下会遇到什么——源码 checkout、SSH 会话或严格网络。它们是当前包约束,不是通用的浏览器对比或任务积压。 - **前端必须已构建**——源码 checkout 需要先运行 `pnpm run build`;dist 缺失时启动会以构建提示停止,且没有从源码直接服务的回退路径。 -- **监听器不提供 TLS**——请用终止 TLS 的代理保护外部链路;HTTP 公告根会以明文发送凭据。 -- **LAN 地址只在启动时采样一次**——启动后的网卡变化不会重新公告;打印的 LAN URL 始终与采样结果一致。 +- **TLS 需自行启用且不代管**——只有传入 `--tls-cert` 与 `--tls-key`(完整证书链与不带口令的密钥)时监听器才提供 HTTPS;DSH 从不获取、续期或监视证书,更换文件需要重新加载监听器或重启进程。浏览器必须信任该证书,其名称必须覆盖你所访问的 authority,且证书与 `--public-url` 相互独立。 +- **未启用 TLS 时外部链路是明文 HTTP**——请用终止 TLS 的代理保护它,不要在不受信任的网络上发送启动 URL。 - **只能观察到交接的启动**——GUI 只报告浏览器被请求打开,而不是它确实打开了;之后的浏览器退出永远不会上报,打印的 URL 是你的手动回退路径。 -- **SSH 会话保留 URL 但跳过浏览器交接**——没有公告根时,打印的 URL 指向远端宿主机 loopback 端点;SSH 客户端或编辑器必须暴露并打开本地转发地址。 +- **SSH 会话保留 URL 但跳过浏览器交接**——没有公告根时,打印的 URL 指向远端宿主机的绑定地址;SSH 客户端或编辑器必须暴露并打开本地转发地址。 - **`BROWSER` 覆盖只能来自环境**——被发现的 `.env` 不能设置 `BROWSER`;只有继承值能为自动交接选择可执行文件。 -- **不支持绑定所有网络接口**——出于安全考虑,`--host 0.0.0.0` 会在启动时被拒绝;请使用默认 loopback 主机。 +- **不支持绑定所有网络接口**——出于安全考虑,通配 `--host`(含 Linux 视为 IPv4 any 的 IPv4-mapped 形式)会在启动时被拒绝;请改为绑定本机某个接口的一个具体 IPv4 或 IPv6 地址。 ### 开发备注 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index ec3815178e..18d6983aef 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -7,8 +7,9 @@ # # The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an # ordinary Cordis service. Rows configured from flags inject that service, so -# Loader resolves their expressions only after it exists. The web runtime then -# provides bind-dependent `webRuntime` values to the trust fence. +# Loader resolves their expressions only after it exists. The web-runtime row +# validates the advertised public URL; Connection reads invocation authorities +# directly from `webStartup`. # `dsh --profile web --help` provides no `webStartup`, so no server binds. # ── surface-specific values the base deliberately omits ───────────────────── @@ -173,28 +174,26 @@ # webStartup provider, with these deployment fallbacks. The dist is served # by the web-runtime row below through the fallback seat. Routing and # browser-facing asset paths do not depend on the advertised URL, which the - # web-runtime row owns. + # web-runtime row owns; Connection reads trust authorities from webStartup. + # This row rejects wildcard binds. - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: host: !!js ctx.webStartup.host ?? '127.0.0.1' port: !!js ctx.webStartup.port ?? 3080 + tls: !!js ctx.webStartup.tls compression: gzip compressionLevel: 1 compressionThresholdBytes: 1024 # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the - # frontend-static fallback owner, registers the web-surface prompt - # section and the bash runtime variable, prints the URL line, and opens the - # canonical URL after the full Loader tree settles and the required- - # entry audit passes. Invocation-only values come from the webStartup - # provider; the advertised application URL is this row's `publicUrl` (the - # loopback URL when unset). After the server binds, this row samples LAN - # trust once and provides `webRuntime`. A complete agent-preset persona - # suppresses the prompt section for that agent while retaining the - # host-owned shell variable. + # frontend-static fallback owner, registers the bash runtime variable, + # prints the URL line, and opens the canonical URL after the full Loader + # tree settles and the required-entry audit passes. Invocation-only values + # come from the webStartup provider; the advertised application URL is + # this row's `publicUrl`, otherwise the bind-address URL. - id: web-runtime name: '@deepseek-ai/dsh-web-app' inject: [webStartup] @@ -203,7 +202,14 @@ printUrl: true publicUrl: !!js ctx.webStartup.publicUrl surfaceContext: true - trustedHosts: !!js ctx.webStartup.trustedHosts + + # Model context for an agent that inspects or extends the harness serving + # this GUI: value-free prompt sections plus the current checkout and GUI + # URL as optional runtime context. A complete agent-preset persona replaces + # the sections, and a persona that suppresses optional runtime context + # also drops the two value lines. + - id: harness-dev-context + name: '@deepseek-ai/dsh-harness-dev-context' # The client-plugin reload chain, always mounted: it is idle until a # rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It @@ -223,16 +229,16 @@ # Owns both ends of the web transport: node half binds the gateway to the # webserver under /api; browser half is the fetch/SSE client. Every API - # request and upgrade passes the loopback plus trustedHosts policy; an - # advertised public URL contributes nothing to it. + # request and upgrade passes the loopback, listener-bind, or trustedHosts + # policy; an advertised public URL contributes nothing to it. - id: connection name: '@deepseek-ai/dsh-client-connection' - inject: [webRuntime] + inject: [webStartup] config: - # LAN literals derived from the active bind plus --trusted-host extras. - # A deployment adding authorities keeps this expression and concatenates - # its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts]. - trustedHosts: !!js ctx.webRuntime.trustedHosts + # Keep invocation authorities (each --trusted-host value) when adding + # deployment values, for example: ['app.internal', ...ctx.webStartup.trustedHosts]. + # The listener's bind IP is accepted separately from this list. + trustedHosts: !!js ctx.webStartup.trustedHosts # Raw Blob and ReadableStream uploads run independently of Connection's # RPC and generation service. @@ -377,6 +383,10 @@ - id: ui-reference name: '@deepseek-ai/dsh-client-ui-reference' + # Display masking for recognized credentials in sent user text; remove + # this entry to draw credentials as written. + - id: ui-secret-mask + name: '@deepseek-ai/dsh-client-ui-secret-mask' # Background jobs: the session-header roster with on-demand streaming # record panels over the session job-output service. diff --git a/packages/bundle/web-app/locale/en.json b/packages/bundle/web-app/locale/en.json new file mode 100644 index 0000000000..38435d75ce --- /dev/null +++ b/packages/bundle/web-app/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "web-app" + } +} diff --git a/packages/bundle/web-app/locale/startup/en.json b/packages/bundle/web-app/locale/startup/en.json new file mode 100644 index 0000000000..517b230f59 --- /dev/null +++ b/packages/bundle/web-app/locale/startup/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "web-app/startup" + } +} diff --git a/packages/bundle/web-app/locale/startup/zh.json b/packages/bundle/web-app/locale/startup/zh.json new file mode 100644 index 0000000000..71c9dfc066 --- /dev/null +++ b/packages/bundle/web-app/locale/startup/zh.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Web 启动配置" + } +} diff --git a/packages/bundle/web-app/locale/zh.json b/packages/bundle/web-app/locale/zh.json new file mode 100644 index 0000000000..c224b7e344 --- /dev/null +++ b/packages/bundle/web-app/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Web 应用运行支持", + "description": "提供前端页面资源,并连接 Web 应用所需的运行配置。" + } +} diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 81c3782121..f831a62741 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", - "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.2.1-alpha.1", + "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, bash runtime variables, URL line)", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -25,9 +25,13 @@ "./cordis.patch.yml": "./cordis.patch.yml", "./presets/*.patch.yml": "./presets/*.patch.yml", "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./startup/locale/*.json": "./locale/startup/*.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", + "locale/startup/*.json", "lib/index.js", "lib/startup.js", "cordis.patch.yml", @@ -92,6 +96,7 @@ "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-client-ui-reference": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-secret-mask": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-schedule": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", @@ -180,13 +185,13 @@ "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*", - "@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*" + "@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*", + "@deepseek-ai/dsh-harness-dev-context": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/cordis-plugin-loader": "workspace:~", - "@deepseek-ai/dsh-shell-env": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*" + "@deepseek-ai/dsh-shell-env": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -198,7 +203,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-shell-env": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-typert-registry": "workspace:*", "@deepseek-ai/libreoffice-kit": "^0.1.5", "@deepseek-ai/dsh-otel": "workspace:*" diff --git a/packages/bundle/web-app/presets/cordis.patch.yml b/packages/bundle/web-app/presets/cordis.patch.yml index 25b7ddfa0f..e324a4c837 100644 --- a/packages/bundle/web-app/presets/cordis.patch.yml +++ b/packages/bundle/web-app/presets/cordis.patch.yml @@ -51,7 +51,7 @@ name: '@deepseek-ai/dsh-plan-mode' config: section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. diff --git a/packages/bundle/web-app/presets/ptc.patch.yml b/packages/bundle/web-app/presets/ptc.patch.yml index 22e37cbbee..ac0cc611ef 100644 --- a/packages/bundle/web-app/presets/ptc.patch.yml +++ b/packages/bundle/web-app/presets/ptc.patch.yml @@ -52,7 +52,7 @@ name: '@deepseek-ai/dsh-plan-mode' config: section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. diff --git a/packages/bundle/web-app/presets/standard.patch.yml b/packages/bundle/web-app/presets/standard.patch.yml index daf8f63591..05b06df618 100644 --- a/packages/bundle/web-app/presets/standard.patch.yml +++ b/packages/bundle/web-app/presets/standard.patch.yml @@ -52,7 +52,7 @@ name: '@deepseek-ai/dsh-plan-mode' config: section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index bf1aec78c1..b93b559620 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -4,41 +4,50 @@ * manifest field). The plugin owns the browser-surface glue: it resolves * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the - * harness-source and web-surface prompt sections, the bash-visible web runtime - * variable, the process-token URL line, and the default-browser handoff. An - * advertised `publicUrl` replaces the published root — the loopback URL - * otherwise. App command-line values arrive through the `webStartup` service - * expressions in the bundle patch. + * bash-visible web runtime variable, the process-token URL line, and the + * default-browser handoff, and provides the `webApp` service. An advertised + * `publicUrl` replaces the published bind-address URL. Non-loopback + * HTTP binds trigger an exposure warning. App command-line values arrive + * through the `webStartup` service expressions in the bundle patch. * @module @deepseek-ai/dsh-web-app */ import { spawn, type ChildProcess } from 'node:child_process' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' -import { networkInterfaces } from 'node:os' -import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { addHarnessSourceSection, auditStartupEntries } from '@deepseek-ai/dsh-app-boot' +import { auditStartupEntries } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-client-connection' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' +import { bindUrlHost, isLoopbackHost } from '@deepseek-ai/dsh-host-webserver' import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/cordis-plugin-loader' -import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-shell-env' import { parsePublicUrl } from './public-url.ts' +/** The Web GUI this process serves, provided by the `web-runtime` row. */ +export interface WebApp { + /** + * The URL this GUI advertises, also used for `DSH_WEB_URL` and the startup URL line. + * @returns the configured `publicUrl`, otherwise the listener origin. + */ + getAdvertisedUrl(): string +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** The Web GUI this process serves; absent when no Web runtime is mounted. */ + webApp: WebApp + } +} + /** Stable Cordis plugin name. */ export const name = 'web-app' -/** This dsh installation's root, from either this package's source or built entry. */ -const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) const ANNOUNCED_ROOTS = new WeakSet() -/** Runtime service that releases Web rows after bind-dependent values resolve. */ -const WEB_RUNTIME_SERVICE = 'webRuntime' - /** Services required before the web runtime can mount. */ export const inject = ['webServer'] @@ -49,22 +58,18 @@ export interface Config { /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** - * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive - * layer can turn it off when its user is not in the GUI, so the - * orientation text would be false. + * Register the `DSH_WEB_URL` bash variable naming this GUI. A one-shot + * non-interactive layer can turn it off when its user is not in the GUI. */ surfaceContext: boolean /** * Canonical HTTP(S) root to advertise in the printed and opened URL, - * `DSH_WEB_URL`, and the web-surface orientation, e.g. - * `https://app.example/ui/`, normalized to end in `/`. Advertisement only; - * see [public deployments](../README.md#public-deployments). Absent or YAML - * `null` advertises the loopback URL. + * `DSH_WEB_URL`, and `webApp.getAdvertisedUrl()`, which supplies the Web GUI + * URL in agent runtime context, e.g. `https://app.example/ui/`, normalized to end in `/`. Configures no routing + * or authentication; see [public deployments](../README.md#public-deployments). + * Absent or YAML `null` advertises the bind-address URL. */ publicUrl?: string - /** Explicit `--trusted-host` authorities from this invocation. */ - trustedHosts: string[] } export const Config: z = z.object({ @@ -72,26 +77,11 @@ export const Config: z = z.object({ printUrl: z.boolean().default(true), surfaceContext: z.boolean().default(true), publicUrl: z.transform(z.string(), value => parsePublicUrl(value).href), - trustedHosts: z.array(String).default([]), }) -/** Bind-dependent Web values shared by the trust fence and URL display. */ -export interface WebRuntimeValues { - /** LAN IPv4 literals sampled once when the server binds all interfaces. */ - lanAddresses: string[] - /** LAN literals followed by explicit invocation authorities. */ - trustedHosts: string[] -} - /** Environment variable naming the advertised URL of this Web GUI. */ const DSH_WEB_URL = 'DSH_WEB_URL' as const -// Display-only mirror of the webserver schema's loopback host: the address the -// local URL always prints. Not a source of truth — the schema is. -const LOOPBACK_HOST = '127.0.0.1' -/** The webserver schema's all-interfaces bind literal. */ -const ALL_INTERFACES_HOST = '0.0.0.0' - const BROWSER_OPENER_MODULE = import.meta.resolve('open') const BROWSER_OPENER_PROGRAM = ` @@ -123,51 +113,6 @@ try { } ` -/** - * Resolve one LAN-trust snapshot from the active server bind. - * - * Derived entries are port-less IP literals: DNS rebinding needs an - * attacker-controlled name, while an IP-literal Host is safe on any port and - * an OS-assigned port is unknowable before bind. - * @param bindHost - the active webserver bind host. - * @param extra - explicit `--trusted-host` values, in argument order. - * @returns the LAN display addresses and invocation-derived fence authorities. - */ -export function resolveLanTrust(bindHost: string, extra: readonly string[]): WebRuntimeValues { - const lanAddresses = bindHost === ALL_INTERFACES_HOST - ? Object.values(networkInterfaces()).flat() - .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - .map(iface => iface.address) - : [] - return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } -} - -/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ -function webSurfacePrompt(webUrl: string): string { - const updateContract = 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while ' - + '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. ' - + 'Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. ' - return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` - + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' - + 'The browser provides no implicit DOM, route, or screenshot context. ' - + updateContract - + 'Starting another server does not update this GUI. ' - + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' - + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL.' -} - -/** Resolve the canonical loopback URL from the active Web server. */ -function localWebUrl(ctx: Context): string { - const port = ctx.get('webServer')?.port - if (port === undefined) throw new Error('web-app: webServer service missing while resolving Web runtime') - return `http://${LOOPBACK_HOST}:${String(port)}` -} - -function appRootUrl(ctx: Context, publicUrl: string | undefined): string { - if (publicUrl !== undefined) return publicUrl - return localWebUrl(ctx) -} - /** * Dist location is workspace knowledge of this bundle: anchored on the * frontend package manifest, not configured. Existence is a request-time @@ -233,38 +178,40 @@ export const internals: { } = { resolveDistIndex, openBrowser } /** - * Mount the Web runtime: dist serving, surface prompt, the bash runtime - * variable, the URL line, and the default-browser handoff. + * Mount the Web runtime: dist serving, the bash runtime variable, the URL + * line, and the default-browser handoff. * @param ctx - plugin context carrying the webServer service. * @param config - validated {@link Config}. */ export function apply(ctx: Context, config: Config): void { - const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts) // The schema validates a present string; an explicit YAML `null` bypasses // the string transform and reaches here, meaning unset. const publicUrl = config.publicUrl ?? undefined - // The loopback URL belongs to this host. Under SSH, the operator reaches it + const boundHost = ctx.webServer.host + if (publicUrl === undefined && bindUrlHost(boundHost) === undefined) { + throw new Error( + `web-app: bind address ${JSON.stringify(boundHost)} carries an interface zone id, which no URL can express;` + + ' pass --public-url with the root browsers actually reach it through', + ) + } + if (ctx.webServer.protocol === 'http:' && !isLoopbackHost(boundHost)) { + console.warn(`dsh web: listening on ${boundHost} over plain HTTP; restrict this port to a trusted proxy or network`) + } + // The bind URL belongs to this host. Under SSH, the operator reaches it // through a local forwarding address that this process cannot derive. const handoffBrowser = config.openBrowser && !launchedThroughSsh(launchEnvironmentOf(ctx)) - // Release dependent rows only after bind-dependent trust has been sampled once. - ctx.provide(WEB_RUNTIME_SERVICE, runtime) + /** The advertised root: the canonical `publicUrl`, or the listener's own origin. */ + const appRootUrl = (): string => publicUrl ?? ctx.webServer.getOrigin() + ctx.provide('webApp', { getAdvertisedUrl: appRootUrl } satisfies WebApp) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) if (config.surfaceContext) { - ctx.inject(['systemPrompt'], (promptCtx) => { - addHarnessSourceSection(promptCtx, SOURCE_ROOT) - promptCtx.systemPrompt.section({ - name: 'app:web-surface', - order: promptCtx.systemPrompt.getSectionOrder('WEB_SURFACE'), - text: () => webSurfacePrompt(appRootUrl(promptCtx, publicUrl)), - }) - }) ctx.inject(['shellEnv'], (runtimeCtx) => { runtimeCtx.shellEnv.register({ name: 'web-runtime', variables: { [DSH_WEB_URL]: { description: 'Advertised URL of the DeepSeek Harness Web GUI serving this session.' }, }, - resolve: () => ({ [DSH_WEB_URL]: appRootUrl(runtimeCtx, publicUrl) }), + resolve: () => ({ [DSH_WEB_URL]: appRootUrl() }), }) }) } @@ -277,17 +224,11 @@ export function apply(ctx: Context, config: Config): void { // hand-built tree without a Loader is already the complete tree. const announceReady = (): void => { if (ANNOUNCED_ROOTS.has(connectionCtx.root)) return - const webUrl = appRootUrl(connectionCtx, publicUrl) + const webUrl = appRootUrl() const authenticatedUrl = connectionCtx.connection.authenticatedUrl(webUrl) - // Reuse the exact LAN snapshot provided to the /api trust fence. - const lanCandidate = runtime.lanAddresses[0] - const port = connectionCtx.webServer.port - const lanUrl = lanCandidate === undefined - ? undefined - : connectionCtx.connection.authenticatedUrl(`http://${lanCandidate}:${String(port)}`) ANNOUNCED_ROOTS.add(connectionCtx.root) if (config.printUrl) { - console.log(`dsh web: ${authenticatedUrl}${lanUrl === undefined ? '' : ` (LAN: ${lanUrl})`}`) + console.log(`dsh web: ${authenticatedUrl}`) } if (handoffBrowser) { console.log('dsh web: opening the default browser; pass --no-open to disable') diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 749dd8ea5d..3152e085fb 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -1,6 +1,6 @@ /** * The web app's command-line provider: it parses the `dsh --profile web` flag - * family (`--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`) + * family (`--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`, TLS) * and its `--help` text, then provides the immutable values as * {@link WEB_STARTUP_SERVICE}. Ordinary rows inject that service before * reading it from lazy config. @@ -10,6 +10,7 @@ import { Command } from 'commander' import type { Context } from '@deepseek-ai/cordis' import { parseCmdline } from '@deepseek-ai/dsh-cmdline' +import { isWildcardHost, type TlsConfig } from '@deepseek-ai/dsh-host-webserver' import { parsePublicUrl } from './public-url.ts' /** Stable Cordis plugin name. */ @@ -36,6 +37,8 @@ export interface WebStartupValues { publicUrl?: string /** Explicit `--trusted-host` authorities, in argument order. */ trustedHosts: string[] + /** Server certificate chain and unencrypted private key supplied together. */ + tls?: TlsConfig } /** The web flag family, as commander parsed it. */ @@ -45,6 +48,8 @@ interface WebOptions { port?: string publicUrl?: string trustedHost?: string[] + tlsCert?: string + tlsKey?: string } /** @@ -56,38 +61,49 @@ function webCommand(): Command { .name('dsh --profile web') .description('Serve the DeepSeek Harness browser UI.') .helpOption('-h, --help', 'show this help') - .option('--host ', 'bind host') + .option('--host ', 'bind address: one concrete IPv4 or IPv6 literal of a local interface; wildcard addresses are rejected') .option('--no-open', 'do not open the Web UI in the default browser') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') - .option('--public-url ', 'advertise this HTTP(S) root in the printed, opened, web-surface, and DSH_WEB_URL forms; grants no trust') + .option('--public-url ', 'advertise this HTTP(S) root in the printed, opened, runtime-context, and DSH_WEB_URL forms; grants no trust') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') + .option('--tls-cert ', 'PEM server certificate chain; requires --tls-key') + .option('--tls-key ', 'unencrypted PEM private key; requires --tls-cert') .addHelpText('after', ` Examples: dsh --profile web serve on the composed host and port dsh --profile web --no-open serve without opening a browser dsh --profile web --port 8080 serve on another port + dsh --profile web --host 10.0.0.7 bind one local interface address dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example advertise a prefix-stripping HTTPS proxy entry and admit its authority + dsh --profile web --tls-cert ./server-chain.pem --tls-key ./server-key.pem + serve HTTPS with an existing certificate and key `) } /** * Parse and provide the Web invocation as an ordinary Cordis service. The - * command's action publishes the flags this invocation named; `--host 0.0.0.0`, - * a non-numeric `--port`, or a malformed `--public-url` is a usage error, so on - * rejection (and on `--help`) nothing is provided. + * command's action publishes the flags this invocation named. Invalid flags, + * including an unpaired TLS certificate or key, are usage errors; rejection + * and `--help` provide no service. * @param ctx - plugin context carrying the command line. */ export function apply(ctx: Context): void { const program = webCommand() program.action(() => { const options = program.opts() - if (options.host === '0.0.0.0') { - program.error('error: --host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead') + if (options.host !== undefined && isWildcardHost(options.host)) { + program.error(`error: --host ${options.host} is an unspecified (wildcard) address, which is not supported: binding every interface would expose remote code execution to the network; bind one concrete IPv4 or IPv6 address instead`) } if (options.port !== undefined && !/^\d+$/.test(options.port)) { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } + let tls: TlsConfig | undefined + if (options.tlsCert !== undefined && options.tlsKey !== undefined) { + tls = { certFile: options.tlsCert, keyFile: options.tlsKey } + } else if (options.tlsCert !== undefined || options.tlsKey !== undefined) { + program.error('error: --tls-cert and --tls-key must be supplied together') + } if (options.publicUrl !== undefined) { try { parsePublicUrl(options.publicUrl, '--public-url') @@ -100,6 +116,7 @@ export function apply(ctx: Context): void { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, ...options.publicUrl !== undefined && { publicUrl: options.publicUrl }, + ...tls !== undefined && { tls }, trustedHosts: options.trustedHost ?? [], } satisfies WebStartupValues) }) diff --git a/packages/bundle/web-app/tests/browser-open.spec.ts b/packages/bundle/web-app/tests/browser-open.spec.ts index c9bc0c31cf..bc303e4d21 100644 --- a/packages/bundle/web-app/tests/browser-open.spec.ts +++ b/packages/bundle/web-app/tests/browser-open.spec.ts @@ -72,7 +72,6 @@ describe('web app browser startup', () => { ' openBrowser: true', ' printUrl: false', ' surfaceContext: false', - ' trustedHosts: []', '', ].join('\n')) diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 322e98a818..9a7a01a5cb 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -65,6 +65,7 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ' port: !!js ctx.webStartup.port ?? 3080', ' publicUrl: !!js ctx.webStartup.publicUrl', ' trustedHosts: !!js ctx.webStartup.trustedHosts', + ' tls: !!js ctx.webStartup.tls', '- id: provider', ` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`, '', @@ -100,17 +101,30 @@ describe('web command-line provider', () => { '--port', '8080', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9', + '--tls-cert', 'fullchain.pem', + '--tls-key', 'private-key.pem', ]) expect(values).toEqual({ host: '127.0.0.1', openBrowser: false, port: 8080, trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'], + tls: { certFile: 'fullchain.pem', keyFile: 'private-key.pem' }, }) expect(observed.readerConfig).toEqual(values) expect(observed.exits).toEqual([]) }) + // The webserver suite owns the wildcard spellings; the CLI only has to + // refuse them by address value before any consumer activates. + it.each(['0.0.0.0', '::ffff:0.0.0.0'])('rejects wildcard --host %s before activating consumers', async (host) => { + const { values, observed } = await bootProvider(['--host', host]) + expect(observed.out).toContain(`error: --host ${host} is an unspecified (wildcard) address`) + expect(values).toBeUndefined() + expect(observed.readerConfig).toBeUndefined() + expect(observed.exits).toEqual([1]) + }) + it('leaves deployment values to each consumer when flags omit them', async () => { const { values, observed } = await bootProvider([]) expect(values).toEqual({ openBrowser: true, trustedHosts: [] }) @@ -141,14 +155,6 @@ describe('web command-line provider', () => { expect(observed.exits).toEqual([1]) }) - it('rejects the intentionally unsupported all-interfaces host before the consumer activates', async () => { - const { values, observed } = await bootProvider(['--host', '0.0.0.0']) - expect(observed.out).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead') - expect(values).toBeUndefined() - expect(observed.readerConfig).toBeUndefined() - expect(observed.exits).toEqual([1]) - }) - it('publishes --public-url as advertisement only, leaving the fence to --trusted-host', async () => { const { values, observed } = await bootProvider([ '--public-url', 'https://web.example/ui', @@ -169,6 +175,14 @@ describe('web command-line provider', () => { expect(observed.exits).toEqual([]) }) + it.each(['--tls-cert', '--tls-key'])('rejects an unpaired %s before activating consumers', async (flag) => { + const { values, observed } = await bootProvider([flag, 'server.pem']) + expect(observed.out).toContain('--tls-cert and --tls-key must be supplied together') + expect(values).toBeUndefined() + expect(observed.readerConfig).toBeUndefined() + expect(observed.exits).toEqual([1]) + }) + it('rejects a malformed --public-url before the consumer activates', async () => { // The parser's own suite owns the exhaustive spellings; the provider only // has to fail the invocation before any consumer activates. diff --git a/packages/bundle/web-app/tests/trusted-hosts.spec.ts b/packages/bundle/web-app/tests/trusted-hosts.spec.ts deleted file mode 100644 index 5972569b0d..0000000000 --- a/packages/bundle/web-app/tests/trusted-hosts.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ - -import { describe, expect, it, vi } from 'vitest' -import { resolveLanTrust } from '../src/index.ts' - -vi.mock('node:os', () => ({ - networkInterfaces: () => ({ - lo0: [ - { family: 'IPv4', internal: true, address: '127.0.0.1' }, - ], - en0: [ - { family: 'IPv6', internal: false, address: 'fe80::1' }, - { family: 'IPv4', internal: false, address: '192.168.1.5' }, - ], - en1: [ - { family: 'IPv4', internal: false, address: '10.0.0.7' }, - ], - utun0: undefined, - }), -})) - -describe('resolveLanTrust', () => { - it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => { - const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080']) - expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7']) - expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) - }) - - it('derives nothing for a loopback bind — extras alone stand, no LAN URL to print', () => { - expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) - expect(resolveLanTrust('127.0.0.1', ['lab.internal'])) - .toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) - }) -}) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 6d2b4be201..67a350355d 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -1,11 +1,4 @@ -/** - * Web runtime glue behavior: dist resolution through the bundle's own hook, - * the frontend-static child claiming the fallback seat, the web-surface - * prompt section and bash runtime variables, and readiness publication through - * the URL line and default-browser handoff. The advertised URL comes from this - * plugin's `publicUrl` config (loopback when unset), never from the bound - * webserver. - */ +/** Web runtime URL publication, exposure warnings, and Loader/browser handoff readiness. */ import { EventEmitter } from 'node:events' import { spawn, type ChildProcess } from 'node:child_process' @@ -18,8 +11,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import * as AppBoot from '@deepseek-ai/dsh-app-boot' import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import type { WebServer } from '@deepseek-ai/dsh-host-webserver' +import { bindUrlHost, type WebServer } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' vi.mock('node:child_process', async importOriginal => ({ @@ -27,14 +19,6 @@ vi.mock('node:child_process', async importOriginal => ({ spawn: vi.fn(), })) -vi.mock('node:os', async importOriginal => ({ - ...await importOriginal(), - networkInterfaces: () => ({ - lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], - en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }], - }), -})) - let dist: string | undefined beforeEach(() => { @@ -72,19 +56,17 @@ function stageDist(): string { return index } -/** A fake webServer capturing the fallback seat and index taps. */ -function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: WebServer; seat: () => unknown } { - let fallback: unknown - const server = { +/** Web carrier without a network listener. */ +function fakeHttpServer(host = '127.0.0.1'): { server: WebServer } { + const server: Pick = { host, port: 4567, - registerFallback: (handler: unknown) => { - fallback = handler - return () => { fallback = undefined } - }, + protocol: 'http:', + getOrigin() { return `http://${bindUrlHost(host) ?? ''}:${String(this.port)}` }, + registerFallback: () => () => {}, renderIndex: (html: string) => html, - } as unknown as WebServer - return { server, seat: () => fallback } + } + return { server: server as WebServer } } /** Deterministic Host Connection face for URL publication and frontend injection. */ @@ -115,15 +97,20 @@ interface BashContribution { } describe('web-app runtime glue', () => { - it('mounts dist serving, prompt section, bash variables, and publishes the URL with the LAN snapshot', async () => { + it.each([ + '127.0.0.1', + '::ffff:127.0.0.1', + '::ffff:7f00:1', + ])('publishes the URL and surface context for %s before handing it to the browser', async (bind) => { stageDist() const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) // Editor markers and a project .env SSH value do not establish a remote launch. ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([ { source: 'process', values: { VSCODE_IPC_HOOK_CLI: '/tmp/local-vscode-ipc' } }, { source: 'project-env', path: '/work/.env', values: { SSH_CONNECTION: 'stale-project-value' } }, ])) - const { server, seat } = fakeHttpServer('0.0.0.0') + const { server } = fakeHttpServer(bind) ctx.provide('webServer', server) provideConnection(ctx) const contributions: BashContribution[] = [] @@ -138,34 +125,20 @@ describe('web-app runtime glue', () => { const log = vi.spyOn(console, 'log').mockImplementation((message) => { lifecycle.push(String(message)) }) const openBrowser = vi.fn(async (url: string) => { lifecycle.push(`open:${url}`) }) internals.openBrowser = openBrowser - apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - // Settle the injected registrations. - await new Promise(resolve => setTimeout(resolve, 0)) + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true })) + await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() }) - expect(seat()).toBeDefined() // frontend-static claimed the fallback - expect(ctx.get('webRuntime')).toEqual({ - lanAddresses: ['192.168.1.5'], - trustedHosts: ['192.168.1.5', 'lab.internal'], - }) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)') + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable') expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token') expect(lifecycle).toEqual([ - 'dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)', + 'dsh web: http://127.0.0.1:4567/?token=test-token', 'dsh web: opening the default browser; pass --no-open to disable', 'open:http://127.0.0.1:4567/?token=test-token', ]) - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') - const section = assembly.sections.find(entry => entry.name === 'app:web-surface') - expect(section?.text).toContain('http://127.0.0.1:4567') - // The single update contract: the receiver is always on; no-refresh - // reloads additionally need the rebuild watcher. - expect(section?.text).toContain('pnpm run dev:web') const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime') expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' }) - await ctx.fiber.dispose() + expect(ctx.webApp.getAdvertisedUrl()).toBe('http://127.0.0.1:4567') }) it('advertises and opens the configured public root behind a prefix-stripping proxy', async () => { @@ -192,9 +165,7 @@ describe('web-app runtime glue', () => { printUrl: true, surfaceContext: true, publicUrl: 'https://proxy.example:8443/web', - trustedHosts: ['proxy.example:8443'], })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() }) expect(lifecycle).toEqual([ @@ -202,12 +173,10 @@ describe('web-app runtime glue', () => { 'dsh web: opening the default browser; pass --no-open to disable', 'open:https://proxy.example:8443/web/?token=test-token', ]) - const assembly = await ctx.systemPrompt.assemble() - const section = assembly.sections.find(entry => entry.name === 'app:web-surface') - expect(section?.text).toContain('https://proxy.example:8443/web/') - expect(section?.text).not.toContain('127.0.0.1') const urlContribution = contributions.find(contribution => contribution.name === 'web-runtime') expect(urlContribution?.resolve()).toEqual({ DSH_WEB_URL: 'https://proxy.example:8443/web/' }) + // The service advertises the row's effective publicUrl, whether it came from --public-url or a profile override. + expect(ctx.webApp.getAdvertisedUrl()).toBe('https://proxy.example:8443/web/') }) it('publishes no readiness side effect when printing and browser opening are disabled', async () => { @@ -218,18 +187,14 @@ describe('web-app runtime glue', () => { const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const openBrowser = vi.fn(async () => {}) internals.openBrowser = openBrowser - apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() expect(openBrowser).not.toHaveBeenCalled() - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text) - .toContain('rebuilding the affected Web artifacts') await ctx.fiber.dispose() }) - it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => { + it('skips the surface context when disabled (the one-shot layer): no bash variables', async () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) @@ -241,28 +206,106 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false, trustedHosts: [] })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false })) await new Promise(resolve => setTimeout(resolve, 0)) - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false) - expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false) expect(contributions).toEqual([]) await ctx.fiber.dispose() }) - it('prints the loopback-only URL line when no LAN snapshot exists', async () => { + it.each([ + { bind: '127.0.0.1', printed: '127.0.0.1' }, + { bind: '::1', printed: '[::1]' }, + { bind: '0:0:0:0:0:ffff:7f08:a', printed: '127.8.0.10' }, + { bind: '::ffff:127.0.0.1%lo', printed: '127.0.0.1' }, + { bind: '::0.0.0.1', printed: '[::1]' }, + { bind: '::0.0.0.1%lo', printed: '[::1]' }, + ])('prints the $bind loopback address without warning or opening a browser', async ({ bind, printed }) => { stageDist() const ctx = new Context() - ctx.provide('webServer', fakeHttpServer().server) + onTestFinished(() => ctx.fiber.dispose()) + ctx.provide('webServer', fakeHttpServer(bind).server) provideConnection(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] })) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') - await ctx.fiber.dispose() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true })) + await vi.waitFor(() => { expect(log).toHaveBeenCalledWith(`dsh web: http://${printed}:4567/?token=test-token`) }) + expect(warn).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() }) + it.each([ + { bind: '10.1.2.3', printed: '10.1.2.3' }, + // `::127.0.0.1` names the IPv6 address `::7f00:1`, so the tail spelling an + // IPv4 loopback address makes it no loopback bind. + { bind: '::127.0.0.1', printed: '[::7f00:1]' }, + ])('warns and advertises the $bind bind address when the listener is not loopback', async ({ bind, printed }) => { + stageDist() + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.provide('webServer', fakeHttpServer(bind).server) + provideConnection(ctx) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false })) + await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() }) + + // A non-loopback bind has no loopback listener, so the printed and opened + // root names the bind address; startup warns that the port is plain HTTP. + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + `dsh web: listening on ${bind} over plain HTTP; restrict this port to a trusted proxy or network`, + ) + expect(log).toHaveBeenCalledWith(`dsh web: http://${printed}:4567/?token=test-token`) + expect(openBrowser).toHaveBeenCalledWith(`http://${printed}:4567/?token=test-token`) + }) + + + it('drops an IPv6 zone id from the advertised URL', async () => { + stageDist() + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + // `::1%lo` listens on IPv6 loopback, but a zone id cannot appear in a URL; + // the advertised form must stay a parseable address. + ctx.provide('webServer', fakeHttpServer('::1%lo').server) + provideConnection(ctx) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: false })) + await vi.waitFor(() => { expect(log).toHaveBeenCalled() }) + + expect(log).toHaveBeenCalledWith('dsh web: http://[::1]:4567/?token=test-token') + }) + + it.each(['fe80::1%lo', '::127.0.0.1%lo'])('refuses its own activation when %s carries an interface zone id', async (bind) => { + stageDist() + // A zone id cannot appear in a URL, and a non-loopback bind that names one + // has no root this runtime can prove reachable: this row must fail rather + // than let a boot continue without an advertised entry point. + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.provide('webServer', fakeHttpServer(bind).server) + expect(() => { apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: false })) }) + .toThrow(/carries an interface zone id, which no URL can express; pass --public-url/u) + + // A declared root makes the same bind usable. + const declared = new Context() + onTestFinished(() => declared.fiber.dispose()) + declared.provide('webServer', fakeHttpServer(bind).server) + provideConnection(declared) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + apply(declared, new Config({ + openBrowser: false, printUrl: true, surfaceContext: false, publicUrl: 'https://app.example/ui/', + })) + await vi.waitFor(() => { expect(log).toHaveBeenCalled() }) + expect(log).toHaveBeenCalledWith('dsh web: https://app.example/ui/?token=test-token') + }) + + it('does not publish readiness again when Connection reloads', async () => { stageDist() const ctx = new Context() @@ -270,7 +313,7 @@ describe('web-app runtime glue', () => { const first = ctx.plugin((connectionCtx: Context) => { provideConnection(connectionCtx) }) await first const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledTimes(1) @@ -288,7 +331,7 @@ describe('web-app runtime glue', () => { ctx.provide('webServer', fakeHttpServer().server) provideConnection(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: false, publicUrl: null as never, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: false, publicUrl: null as never })) await vi.waitFor(() => { expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') }) }) @@ -304,7 +347,7 @@ describe('web-app runtime glue', () => { const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const openBrowser = vi.fn(async () => {}) internals.openBrowser = openBrowser - apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') expect(openBrowser).not.toHaveBeenCalled() @@ -324,7 +367,7 @@ describe('web-app runtime glue', () => { const settlement = new Promise((resolve) => { release = resolve }) provideLoader(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(settled, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(settled, new Config({ openBrowser: true, printUrl: true, surfaceContext: true })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() expect(openBrowser).not.toHaveBeenCalled() @@ -342,7 +385,7 @@ describe('web-app runtime glue', () => { failed.provide('webServer', fakeHttpServer().server) provideConnection(failed) provideLoader(failed, async () => { throw new Error('boot failed') }) - apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() expect(openBrowser).not.toHaveBeenCalled() @@ -361,7 +404,7 @@ describe('web-app runtime glue', () => { let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) provideLoader(torn, () => tornSettlement) - apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true })) await new Promise(resolve => setTimeout(resolve, 0)) await child.dispose() // the webServer service goes away releaseTorn!() @@ -392,7 +435,7 @@ describe('web-app runtime glue', () => { const openBrowser = vi.fn(async () => {}) internals.openBrowser = openBrowser const audit = vi.spyOn(AppBoot, 'auditStartupEntries') - apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false })) await vi.waitFor(() => { expect(audit).toHaveBeenCalledOnce() }) await Promise.allSettled(audit.mock.results.map(result => result.value as Promise)) if (announces) { @@ -404,22 +447,6 @@ describe('web-app runtime glue', () => { } }) - it('fails loud when the prompt section resolves against a portless webserver', async () => { - stageDist() - const ctx = new Context() - // A webserver whose bound port is gone (torn down mid-request): the - // section must throw, never render a URL with an undefined port. - const { server } = fakeHttpServer() - Object.defineProperty(server, 'port', { get: () => undefined }) - ctx.provide('webServer', server) - provideConnection(ctx) - apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await new Promise(resolve => setTimeout(resolve, 0)) - await expect(ctx.systemPrompt.assemble()).rejects.toThrow('webServer service missing') - await ctx.fiber.dispose() - }) - it('anchors the dist index on the frontend package manifest without requiring a built dist', () => { // The production resolver (not the test hook): the anchor resolves on any // checkout, built or not — dist existence is the fallback owner's @@ -439,7 +466,7 @@ describe('web-app runtime glue', () => { internals.openBrowser = vi.fn(async () => { throw failure }) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {}) - apply(ctx, new Config({ openBrowser: true, printUrl: false, surfaceContext: false, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: true, printUrl: false, surfaceContext: false })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable') expect(diagnostic).toHaveBeenCalledWith( diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index d4a74c09ba..2f7f148e60 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -35,9 +35,6 @@ { "path": "../../util/launch-environment" }, - { - "path": "../../core/system-prompt" - }, { "path": "../../shell/shell-env" }, diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 2d39d6b3ca..16956ca71d 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -8,7 +8,7 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-..` (for example, `'tool.call.toolview'`). 3. **Component props are the five shares, all derived**: `PropsRuntime` (owner data plus scope/global seats), `PropsRenderSlots` (children), `PropsRenderFactories` (`renderFactorySlot`), `PropsStore` (store), and the inject face. Never hand-write or locally re-type a derived member. Factory props follow the corresponding `Factory*PropsOf` aliases. 4. **Hooks are framework-made only**: standing seats include `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot`, and `renderFactorySlot`; Factory definitions additionally receive `useFactorySlot`. The renderer also binds `use` hooks from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) @@ -16,6 +16,7 @@ The [Slots reference](../../docs/subsystems/slots.md) owns the current design; t 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation API. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to registrations inside `apply`. A Factory may instead receive the factory itself to create one handle per committed render position. Production code never calls the factory or `.create()` outside registration; tests do (that is the sanctioned zero-machinery path). 7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact uses the reserved `hooks` compartment (bare observables the renderer binds to `use`; components never see the sources). The plugin may use only the dependencies named by its `inject` declaration; there is no wider ctx to reach for. 8. **Factory local Components keep stable identities**: pass module-level function Components through `renderFactorySlot(..., { slots })`. Creating a Component function during render intentionally remounts that local subtree and resets its local state; the caller owns that behavior. +9. **A `key` states identity**: pass one when it names which entity or instance the element represents. A key that only silences a warning, or that encodes render order, misstates identity and makes a later remount or reuse decision wrong. ## Reactive read and contract-currency discipline @@ -27,6 +28,8 @@ How live data reaches render code, and what UI domains may share: 4. **UI domains share JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions use these values. Tool `args` may carry the [lazy argument reader](../../.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.md); its Definition republishes the block when an observed answer changes. Transient file-read and document-preview content may also carry `Uint8Array`: published buffers are borrowed read-only, copied before Worker transfer, and never persisted in layout or Session JSON. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots). 5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves). 6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering. +7. **Resource consumers go through `useResource`.** An element showing a resource reads it through the injected `useResource` hook, and a domain wrapper composes its own hook over it rather than reimplementing the fetch, cache, or polling below. Wrapping is free at the business layer as long as the bottom layer is not rebuilt ([client resources](../../docs/subsystems/client-resources.md)). +8. **Effects do not synchronize state.** `useLayoutEffect` is not a default and `useEffect` is not the fix: a layout measurement answers why it happens here, why CSS cannot express it, and why it must be pixel-based, then takes an injected measurement hook. An effect never pushes render-derivable data back to a parent through a props setter; decide it during render, give the host a render-time channel, or move it to the inject face. ## Export discipline (client plugin packages) @@ -66,6 +69,7 @@ Npm sections describe installation and development relationships; each build fac 5. **Only Host runtime libraries require installation dependencies.** Browser-only third-party implementations belong in `devDependencies`, whether inlined into `lib/client.js` or retained as bare imports in a static browser library. Static libraries are Web-shell build inputs; the final Vite build merges their imports and styles. Distributed browser code remains subject to runtime license disclosure ([reference](../../.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md)). 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. +8. **Only the root manifest declares React type packages.** `@types/react` and `@types/react-dom` belong only in the root `package.json` `devDependencies`, and `verify-package-dependencies` rejects them in every manifest under `apps/*`, `packages/*/*`, and `vendor/*`; packages still declare the `react` and `react-dom` they import. Each package-local pnpm link is one more location TypeScript scans when it computes module specifiers for every JSX file ([rationale](../../.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md#1-only-the-root-manifest-declares-react-type-packages)). ## Build-time browser environment @@ -73,11 +77,11 @@ Client business code may statically read `process.env.DSH_CLIENT_*`; every refer ## Shared modules and the module graph -A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static Client libraries; `PRELOADED_CLIENT_EXTERNALS` is reserved for dynamic rows whose factories must arrive before shell boot and is empty when no such row exists. +A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, selected third-party libraries, and static Client libraries; `PRELOADED_CLIENT_EXTERNALS` is reserved for dynamic rows whose factories must arrive before shell boot and is empty when no such row exists. -1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, `client/store`, `ui-primitives`, `ui-slots`, or `ui-dockkit` in package manifests. +1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat entries from `PLATFORM_MODULES` in `dsh.client.external`; npm dependency declarations still follow the dependency rules above. 2. **`dsh.client.external` is not a feature-plugin dependency mechanism.** Only infrastructure, transport, or generated assembly may add a package-specific non-baseline value request whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row. -3. **Silence means a private copy.** Ordinary third-party implementation libraries may be bundled independently. A value reached only through `import type` is erased and creates no request. +3. **Silence means a private copy.** Third-party implementation libraries outside the baseline may be bundled independently. A value reached only through `import type` is erased and creates no request. 4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol. 5. **Validate both sides.** The dynamic build preset externalizes the baseline and rejects undeclared workspace value imports; [`verify-client-packages`](../../scripts/verify-client-packages.ts) rejects malformed or redundant requests, missing suppliers, and synchronous request cycles. 6. **Package-local dynamic chunks are self-contained.** Source uses `import()` and the shared preset emits `require.async("./client..js")`; an entry or chunk must not retain a synchronous relative `require("./client*.js")`. Keep every static relative dependency inside its owning output chunk rather than relying on a sibling-chunk graph the runtime does not support. @@ -139,7 +143,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons): -1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section. +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section. 2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the computed runtime resolution, which is installed as an interception on Node's resolvers — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph). 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. @@ -150,7 +154,7 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is a com 1. **Check the [ui-primitives catalog](ui-primitives/README.md#component-catalog) before writing a control.** A plugin cannot import another plugin's component, so `ui-primitives` is the only place a control can be shared; the catalog states when to reuse, when to promote, and when your own package is the right home. -2. Compose through `register`: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component. Use a Component Factory only for a reusable assembly rendered independently under unrelated parents; see the [Slots reference](../../docs/subsystems/slots.md). +2. Compose through `register`: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component. A component or assembly designed for reuse may instead use a Component Factory; one-off components need not become Factories. See the [Slots reference](../../docs/subsystems/slots.md). 3. Type ordinary props from the five shares (`PropsRuntime` & `PropsRenderSlots` & `PropsRenderFactories` & `PropsStore` & inject face), and use the derived `Factory*PropsOf` aliases for Factory Components. Shared or remount-surviving state goes in a declared store; component-private state stays local. 4. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. 5. Tokens only in CSS; product copy follows the localization rule above; English comments. diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 766b7d16d0..f4e03603ff 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /client-web-gui-browser-half/packages: - en: 1aa93b8088fb1962 - zh: 10feaed78bc6408c + en: e0bdfa58440be810 + zh: a006bb2e73f3f264 /client-web-gui-browser-half/related-documentation: en: d1d506e68fab7333 zh: 1a90fe6f3f8f24a9 diff --git a/packages/client/README.md b/packages/client/README.md index 04eb19d8f7..19ed68cdbc 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -63,6 +63,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-input-trigger/`](ui-input-trigger/README.md) | Coordinates inline command and reference suggestions | — | | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — | +| [`ui-secret-mask/`](ui-secret-mask/README.md) | Masks recognized credentials in sent user text | — | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — | | [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — | | [`ui-schedule/`](ui-schedule/README.md) | Lists the current Session's active reminders in a read-only header catalog | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 2ebc91a3e1..1ef85c42b5 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -63,6 +63,7 @@ kind: "package-group" | [`ui-input-trigger/`](ui-input-trigger/README.zh.md) | 协调内联命令与引用建议 | — | | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — | +| [`ui-secret-mask/`](ui-secret-mask/README.zh.md) | 遮挡已发送用户文本中可识别的凭据 | — | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent 导航、子级 transcript(文本记录)状态与内联引用 | — | | [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — | | [`ui-schedule/`](ui-schedule/README.zh.md) | 在只读标题栏目录中列出当前会话中生效的提醒 | — | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 7498a796dd..9efedba955 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 13b54969ff1d00a4 zh: 4d2ffff0445472ed /deepseek-ai-dsh-client-connection/browser-authentication-and-request-trust: - en: ab52ef146d0b5b42 - zh: 6314d50cfe5683e1 + en: 44956be3cc11908a + zh: d518f765d58f3558 /deepseek-ai-dsh-client-connection/connection-generation: en: 116f94e44c0d87e7 zh: 59ef48984154d858 @@ -30,8 +30,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-connection/known-limitations-and-deferred-work: - en: 2c964a7546604347 - zh: c30b59fc92a01c36 + en: ad73c3b97f348ad1 + zh: fec950f970112182 /deepseek-ai-dsh-client-connection/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index f973fe1d4e..d6cb58d42c 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -38,9 +38,11 @@ The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/re Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens its application URL with `?token=...`, preserving the caller's authority and mount; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `./`, which drops the token and keeps the request's directory. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token. -The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP. +The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`, and carry `Secure` exactly when the listener that received the index request serves TLS; only that listener's own protocol decides, never a `publicUrl` announcement or a forwarded header. -Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). +HTTPS cookie names and signed audiences include the `https:` scheme, so HTTP cookies cannot authenticate to a TLS listener even on the same hostname and port. Default ports normalize under the listener's scheme. Existing HTTP cookie audiences remain unchanged. + +Before authentication, every request passes `src/api-request-trust.ts`. Its `Host` must be loopback, equal the listener's bind IP literal on any port, or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries. Host and configured entries use the listener's scheme for WHATWG default-port normalization, not a forwarded header or `Origin`. An attached HTTP(S) `Origin` must name the same authority, with Host parsed under that Origin's scheme to support HTTPS proxies over HTTP upstreams; `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. `allowsRemoteAuthorities` reports whether `trustedHosts` permits a non-loopback authority; accepting the bind IP does not change that list. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` is unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract. @@ -72,7 +74,7 @@ None; this package neither assembles nor sends a provider request. - **Buffered `/api` routes retain each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) bounds ordinary image and RPC envelopes. Opt-in streaming routes receive backpressured chunks and bypass the aggregate cap; route implementations own persistence, cancellation, and any storage quota. -- **The browser cookie is not marked `Secure`** — loopback HTTP is the shipped transport, so exposing the same authority over plaintext networking can expose the bearer cookie in transit. +- **A plain-HTTP listener exposes the bearer cookie in transit** — such a deployment cannot mark the browser cookie `Secure`, so serving the same authority over a network can leak the session cookie; configure TLS on the Web carrier ([`dsh-host-webserver`](../../host/webserver/README.md)) to have the cookie marked `Secure` and confined to TLS. - **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 246d875586..6ec954f84d 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -38,9 +38,11 @@ kind: "package-reference" 每个 Host RPC 方法和 WebSocket 流都要求一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的应用 URL,保留调用方的 authority 与挂载;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `./`,移除令牌并保留请求目录。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。 -cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。 +cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;收到该 index 请求的监听器提供 TLS 时才带上 `Secure`,且只由该监听器自身的协议决定,`publicUrl` 声明与转发头都不参与判断。 -认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 +HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使 hostname 和 port 相同,HTTP cookie 也无法通过 TLS 监听器的认证。默认端口按监听器的 scheme 归一化。既有 HTTP cookie audience 保持不变。 + +认证之前,每个请求都经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback、等于监听器的绑定 IP 字面量(端口不限),或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口。Host 与配置条目按监听器的 scheme 做 WHATWG 默认端口归一化,不采信转发 header 或 `Origin`。附带的 HTTP(S) `Origin` 必须指向同一 authority;此时按 Origin 的 scheme 解析 Host,以支持 HTTPS 代理转发到 HTTP 上游;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。`allowsRemoteAuthorities` 报告 `trustedHosts` 是否允许非 loopback authority;接受绑定 IP 不会改变该列表。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。 @@ -72,7 +74,7 @@ API Gateway Client 把内部 `$events` 逻辑流注册为唯一 generation sourc - **缓冲型 `/api` 路由会把每个请求体保留在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)限制普通图片与 RPC 信封。显式启用的流式路由接收带背压的分块并绕过总量上限;路由实现负责持久化、取消与存储配额。 -- **浏览器 cookie 不带 `Secure`**:当前随产品提供的传输方式是 loopback HTTP;若部署经明文网络暴露同一 authority,bearer cookie 可能在传输中泄露。 +- **明文 HTTP 监听器会在传输中暴露 bearer cookie**:此类部署无法给浏览器 cookie 加 `Secure`;若把同一 authority 暴露到网络上,会话 cookie 可能泄露;在 Web 载体([`dsh-host-webserver`](../../host/webserver/README.zh.md))上配置 TLS 即可让 cookie 带上 `Secure` 并只经 TLS 发送。 - **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话。 diff --git a/packages/client/connection/locale/en.json b/packages/client/connection/locale/en.json new file mode 100644 index 0000000000..85615ed94d --- /dev/null +++ b/packages/client/connection/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "connection" + } +} diff --git a/packages/client/connection/locale/zh.json b/packages/client/connection/locale/zh.json new file mode 100644 index 0000000000..642e58d1cd --- /dev/null +++ b/packages/client/connection/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "客户端连接", + "description": "建立已认证的客户端通信,并管理连接生命周期。" + } +} diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index b233722f10..21e395fb6f 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport and generation lifecycle", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -36,9 +37,10 @@ "dependencies": { "@deepseek-ai/dsh-credentials": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 1065dfe876..a2fb78ec6c 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -4,13 +4,12 @@ * the attacker's domain while the socket reaches this server) and cross-site * requests fired from a malicious page. The Host fence binds every request, * browser-looking or not: over plain HTTP a browser attaches neither Origin - * nor Fetch-Metadata to reads (images and navigations — those - * headers go only to trustworthy destinations), so an unmarked request may - * still be a rebound browser read and Host is the one header rebinding cannot - * forge. Non-browser and remote clients pass the same fence via loopback, - * deployment-derived LAN IP literals, or a declared `trustedHosts` authority. - * Network reachability and authentication stay out of scope: binding policy - * belongs to the webserver config, and this fence is not an auth layer. + * nor Fetch-Metadata to reads (images and navigations — those headers go only + * to trustworthy destinations), so an unmarked request may still be a rebound + * browser read and Host is the one header rebinding cannot forge. Non-browser + * and remote clients pass the same fence via loopback, the listener's bind IP + * literal, or a declared `trustedHosts` authority. Binding policy belongs to + * the webserver config; this fence never establishes identity. */ import { isLoopbackHostname } from './loopback-hostname.ts' @@ -22,11 +21,16 @@ function header(headers: ConnectionTrustRequest['headers'], name: string): strin return typeof value === 'string' ? value : undefined } -/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ -function parseAuthority(authority: string): URL | undefined { +/** + * Normalized URL of a bare authority (hostname lowercased, default port + * stripped, IPv6 bracketed), or undefined when unparsable. + * @param authority - `host` or `host:port` text. + * @param scheme - special scheme to parse under; its default port is the one stripped. + */ +function parseAuthority(authority: string, scheme: 'http:' | 'https:' = 'http:'): URL | undefined { try { - // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws. - return new URL(`http://${authority}`) + // http: and https: are WHATWG "special schemes": parsing yields a non-empty hostname or throws. + return new URL(`${scheme}//${authority}`) } catch { return undefined } @@ -60,21 +64,19 @@ export function assertTrustedAuthority(entry: string): void { * shapes like `host:port ` as port-less. */ function canonicalAuthority(entry: string, entryUrl: URL): string { - // An authority that parsed under http cannot fail under https. - const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port + const port = entryUrl.port !== '' ? entryUrl.port : new URL(`${entryUrl.protocol === 'http:' ? 'https:' : 'http:'}//${entry}`).port return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}` } /** * Whether the request authority matches a `trustedHosts` entry. An entry with * an explicit port matches that exact authority; a port-less entry matches the - * hostname on any port (the shape the CLI derives for IP-literal LAN serving, - * where the bound port may be OS-assigned). Both sides compare through WHATWG - * normalization, so case and a redundant `:80` never decide trust. + * hostname on any port. Both sides compare through WHATWG normalization, + * so case and a redundant scheme-default port never decide trust. */ -function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean { +function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[], protocol: 'http:' | 'https:'): boolean { return trustedHosts.some((entry) => { - const entryUrl = parseAuthority(entry) + const entryUrl = parseAuthority(entry, protocol) if (entryUrl === undefined) return false return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname @@ -82,13 +84,46 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool }) } +/** + * Classify an authority already accepted by {@link assertTrustedAuthority}. + * @param entry - the configured authority, verbatim. + * @returns true unless the entry parses as a loopback hostname. + */ +export function isRemoteAuthority(entry: string): boolean { + const url = parseAuthority(entry) + return url === undefined || !isLoopbackHostname(url.hostname) +} + +/** + * Whether a parsed authority names the deployment's own bind address. A browser + * dialing that literal sends it as Host, and accepting it grants nothing + * further: the literal is no rebinding target, the Origin and cross-site checks + * still apply, and it stays outside `trustedHosts`. Matching ignores the port, + * like a port-less entry, and any `%zone` id (an interface selector, not + * address text). + */ +function isBindAddressAuthority(hostUrl: URL, bindHost: string | undefined): boolean { + if (bindHost === undefined) return false + const zoneAt = bindHost.indexOf('%') + const address = zoneAt === -1 ? bindHost : bindHost.slice(0, zoneAt) + const bindUrl = parseAuthority(address.includes(':') ? `[${address}]` : address) + return bindUrl !== undefined && bindUrl.hostname === hostUrl.hostname +} + /** * Decide whether one /api request may reach the RPC bridge. * @param request - Node HTTP or Fetch request facts (headers). * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. - * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. + * @param bindHost - the listener's own bind IP literal, accepted on any port independently of `trustedHosts`. + * @param protocol - listener protocol for default Host ports; HTTP when no Web carrier is mounted. + * @returns true when the Host is ours (loopback, the bind address, or trusted) and any attached browser markers are same-origin. */ -export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHosts: readonly string[]): boolean { +export function isTrustedApiRequest( + request: ConnectionTrustRequest, + trustedHosts: readonly string[], + bindHost?: string, + protocol: 'http:' | 'https:' = 'http:', +): boolean { // Host fence (DNS-rebinding defense), applied to every request: the browser // fills Host from the URL it believes it is talking to, so a rebound page // carries the attacker's domain here even though the socket lands on this @@ -98,9 +133,11 @@ export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHost // by the rebound page. const host = header(request.headers, 'host') if (host === undefined) return false - const hostUrl = parseAuthority(host) + const hostUrl = parseAuthority(host, protocol) if (hostUrl === undefined) return false - if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false + if (!isLoopbackHostname(hostUrl.hostname) + && !isTrustedAuthority(hostUrl, trustedHosts, protocol) + && !isBindAddressAuthority(hostUrl, bindHost)) return false // Cross-site fence: modern browsers label the initiator relationship on // every fetch; an explicit cross-site marker is refused regardless of Origin. if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false @@ -110,9 +147,30 @@ export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHost // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused. const origin = header(request.headers, 'origin') if (origin === undefined) return true + return isSameAuthorityOrigin(origin, host) +} + +/** + * Compare Host under the Origin's HTTP(S) scheme so default ports normalize + * consistently. The transport may remain HTTP behind an HTTPS proxy. + * @param origin - verbatim Origin header value. + * @param host - verbatim Host header value already accepted by the Host fence. + * @returns true only when the Origin is this listener's own `http(s)` authority. + */ +function isSameAuthorityOrigin(origin: string, host: string): boolean { + let originUrl: URL try { - return new URL(origin).host === hostUrl.host + originUrl = new URL(origin) } catch { return false } + // Only http(s) can name this listener: the opaque `null` already threw above, + // and every other scheme (file:, ftp:) fails here rather than matching by + // hostname alone. + const scheme = originUrl.protocol === 'https:' ? 'https:' + : originUrl.protocol === 'http:' ? 'http:' + : undefined + if (scheme === undefined) return false + const hostUrl = parseAuthority(host, scheme) + return hostUrl !== undefined && originUrl.host === hostUrl.host } diff --git a/packages/client/connection/src/browser-auth.ts b/packages/client/connection/src/browser-auth.ts index 5691180d96..6b01531da6 100644 --- a/packages/client/connection/src/browser-auth.ts +++ b/packages/client/connection/src/browser-auth.ts @@ -66,12 +66,13 @@ function header( return typeof value === 'string' ? value : undefined } -/** Canonical request authority used as the cookie name and signed audience. */ -function requestAuthority(headers: ConnectionTrustRequest['headers']): string | undefined { +/** Protocol-scoped cookie audience; HTTP retains its existing authority-only spelling. */ +function requestAudience(headers: ConnectionTrustRequest['headers'], secure: boolean): string | undefined { const host = header(headers, 'host') if (host === undefined) return undefined try { - return new URL(`http://${host}`).host + const url = new URL(`${secure ? 'https:' : 'http:'}//${host}`) + return secure ? url.origin : url.host } catch { return undefined } @@ -118,8 +119,15 @@ function cookieValue(headerValue: string, name: string): string | undefined { } /** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */ -function sessionCookie(name: string, value: string, expiresAt: number, maxAgeSeconds: number): string { - return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict` +function sessionCookie( + name: string, + value: string, + expiresAt: number, + maxAgeSeconds: number, + secure: boolean, +): string { + const attributes = `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict` + return secure ? `${attributes}; Secure` : attributes } function signature(secret: Buffer, body: string): Buffer { @@ -233,14 +241,18 @@ export class BrowserAuth { * 401 response. * @param req - incoming root or configured-index request. * @param res - response owned when this method returns false. + * @param secure - whether the listener receiving this request serves TLS; the + * minted cookie carries `Secure` when it does. Only that listener's own + * protocol decides, never a `publicUrl` announcement or a forwarded header: + * those describe a hop this process does not terminate. * @returns true only when the caller may serve index.html. */ - authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean { + authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse, secure = false): boolean { /* v8 ignore next -- node:http always supplies url on server requests. */ const url = new URL(req.url ?? '/', 'http://dsh.invalid') const tokens = url.searchParams.getAll(TOKEN_QUERY) if (tokens.length > 0) { - const authority = requestAuthority(req.headers) + const authority = requestAudience(req.headers, secure) if (req.method === 'GET' && url.pathname === '/' && tokens.length === 1 && authority !== undefined && tokenMatches(tokens.join(''), this.launchToken)) { const issuedAt = Date.now() @@ -256,13 +268,13 @@ export class BrowserAuth { 'location': './', 'referrer-policy': 'no-referrer', 'set-cookie': sessionCookie( - cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000), + cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000), secure, ), }) res.end() return false } - if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req)) { + if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req, secure)) { res.writeHead(303, { 'cache-control': 'no-store', 'location': './', @@ -274,7 +286,7 @@ export class BrowserAuth { this.writeUnauthorized(req, res) return false } - if (this.isAuthenticated(req)) return true + if (this.isAuthenticated(req, secure)) return true this.writeUnauthorized(req, res) return false } @@ -282,10 +294,11 @@ export class BrowserAuth { /** * Verify the authority-bound browser cookie on a Host request. * @param request - request headers carrying Host and Cookie. + * @param secure - whether the receiving listener uses TLS, including its default port. * @returns true only for an unexpired cookie signed by this activation's loaded secret. */ - isAuthenticated(request: ConnectionTrustRequest): boolean { - const authority = requestAuthority(request.headers) + isAuthenticated(request: ConnectionTrustRequest, secure = false): boolean { + const authority = requestAudience(request.headers, secure) const rawCookie = header(request.headers, 'cookie') if (authority === undefined || rawCookie === undefined) return false const value = cookieValue(rawCookie, cookieName(authority)) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 647174f743..34e15f90a6 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -94,11 +94,9 @@ export interface ConnectionConfig { recovery?: ConnectionRecoveryConfig /** * Authorities this deployment serves beyond loopback: exact `host:port`, or - * port-less `host` matching any port. The /api trust fence refuses any - * request whose Host is neither loopback nor listed here, so a - * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by; the Web runtime derives LAN IP literals from an active all-interface - * bind. An entry that is not a bare, canonical authority fails plugin load. + * port-less `host` matching any port. The fence accepts loopback and the + * listener's own bind IP independently; other remote authorities require + * an entry here. A non-canonical or non-bare authority fails plugin load. */ trustedHosts?: string[] /** Absolute browser-session lifetime in days. Default: 30. */ diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 0f98bc1f19..44862c770a 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -10,7 +10,7 @@ import { } from './rpc.ts' import { clientRequestSchema } from './rpc-schema.ts' import { bridge } from './http-bridge.ts' -import { isTrustedApiRequest } from './api-request-trust.ts' +import { isRemoteAuthority, isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' import { OperatorPeer } from './operator-peer.ts' @@ -66,6 +66,8 @@ export class HostConnectionService extends Service implements HostConnectionHand private readonly interceptors = new Map() private readonly fetchRoutes = new Map() + readonly allowsRemoteAuthorities: boolean + /** * Provide the Host half over the active HTTP server. * @param ctx - owning Connection plugin context. @@ -80,6 +82,7 @@ export class HostConnectionService extends Service implements HostConnectionHand super(ctx, 'connection') this.operator = new OperatorPeer(ctx) ctx.effect(() => () => this.operator.dispose(), 'client-connection: operator Peer') + this.allowsRemoteAuthorities = trustedHosts.some(isRemoteAuthority) } /** Generic channel registry scoped to the Context reading this service. */ @@ -100,10 +103,11 @@ export class HostConnectionService extends Service implements HostConnectionHand } } - /** Apply the configured Host/Origin fence, then browser authentication. */ + /** Apply the Host/Origin fence, then browser authentication. */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection { - if (!isTrustedApiRequest(request, this.trustedHosts)) return 403 - return this.browserAuth.isAuthenticated(request) ? undefined : 401 + const carrier = this.ctx.get('webServer') + if (!isTrustedApiRequest(request, this.trustedHosts, carrier?.host, carrier?.protocol ?? 'http:')) return 403 + return this.browserAuth.isAuthenticated(request, carrier?.protocol === 'https:') ? undefined : 401 } /** A request that passes the fence and authentication speaks for the operator. */ @@ -112,9 +116,11 @@ export class HostConnectionService extends Service implements HostConnectionHand return rejection === undefined ? { peer: this.operator } : { rejection } } - /** Authenticate an index request through the process-token exchange or cookie. */ + /** + * Authenticate an index request; native TLS listeners mint Secure cookies. + */ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean { - return this.browserAuth.authorizeIndex(request, response) + return this.browserAuth.authorizeIndex(request, response, this.ctx.get('webServer')?.protocol === 'https:') } /** Add this process's launch token to the clean application URL. */ diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 0b8755bf71..cf30df6204 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -198,6 +198,12 @@ export interface HostConnectionHandle { /** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */ readonly operator: PeerScope + /** + * Whether this deployment accepts authorities beyond loopback: true only + * when a validated `trustedHosts` entry names a non-loopback hostname. + */ + readonly allowsRemoteAuthorities: boolean + /** * Compose exact Fetch routes and the shared-channel RPC interceptor. * @param channel - shared channel mounted by Connection. @@ -223,6 +229,8 @@ export interface HostConnectionHandle { /** * Authenticate one frontend index request, owning a token redirect or 401. + * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, + * which only that listener's protocol decides. * @param request - root or configured-index HTTP request. * @param response - response owned when the result is false. * @returns true only when the frontend may serve index.html. diff --git a/packages/client/connection/tests/api-request-trust.host.spec.ts b/packages/client/connection/tests/api-request-trust.host.spec.ts index 359a461e94..9d07ce679f 100644 --- a/packages/client/connection/tests/api-request-trust.host.spec.ts +++ b/packages/client/connection/tests/api-request-trust.host.spec.ts @@ -25,6 +25,26 @@ describe('isTrustedApiRequest', () => { } }) + it('accepts the listener\'s own bind address port-lessly, and nothing that merely resembles it', () => { + // A browser dialing the bind address sends that IP literal as Host; the + // port is not part of the grant. + for (const host of ['10.1.2.3', '10.1.2.3:3080', '10.1.2.3:9999']) { + expect(isTrustedApiRequest(request({ host }), [], '10.1.2.3')).toBe(true) + } + expect(isTrustedApiRequest(request({ host: '10.1.2.4:3080' }), [], '10.1.2.3')).toBe(false) + expect(isTrustedApiRequest(request({ host: 'dsh-direct.lan:3080' }), [], '10.1.2.3')).toBe(false) + expect(isTrustedApiRequest(request({ host: '10.1.2.3:3080' }), [])).toBe(false) + // IPv6 spellings compare through the same normalization as Host, mapped + // forms included; a zone id selects the local interface, not the address. + for (const host of ['[fd00::1]', '[fd00::1]:3080', '[::ffff:a01:203]:3080', '[::ffff:10.1.2.3]:3080']) { + const bindHost = host.startsWith('[::ffff') ? '::ffff:10.1.2.3' : 'fd00::1' + expect(isTrustedApiRequest(request({ host }), [], bindHost)).toBe(true) + } + expect(isTrustedApiRequest(request({ host: '[fd00::2]:3080' }), [], 'fd00::1')).toBe(false) + expect(isTrustedApiRequest(request({ host: '[fd00::1]:3080' }), [], 'fe80::1%lo')).toBe(false) + expect(isTrustedApiRequest(request({ host: '[fe80::1]:3080' }), [], 'fe80::1%lo')).toBe(true) + }) + it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => { expect(isTrustedApiRequest(request({ host: 'evil.example:3080', @@ -33,6 +53,32 @@ describe('isTrustedApiRequest', () => { }), [])).toBe(false) }) + it('compares Origin and Host under the Origin scheme, so HTTPS default ports normalize like HTTP ones', () => { + // A browser omits the default port it dialed, while a proxy or a direct + // client may still write it in Host: both spellings name one authority. + expect(isTrustedApiRequest(request({ host: 'harness.internal:443', origin: 'https://harness.internal' }), ['harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal:443', origin: 'https://harness.internal:443' }), ['harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'https://harness.internal:443' }), ['harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal:80', origin: 'http://harness.internal' }), ['harness.internal'])).toBe(true) + // A TLS-terminating proxy forwards the browser's https Origin to a plain-HTTP upstream. + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'https://harness.internal' }), ['harness.internal'])).toBe(true) + // A real hostname or port difference still decides, in both directions. + expect(isTrustedApiRequest(request({ host: 'harness.internal:443', origin: 'https://harness.internal:444' }), ['harness.internal'])).toBe(false) + expect(isTrustedApiRequest(request({ host: 'harness.internal:80', origin: 'https://harness.internal' }), ['harness.internal'])).toBe(false) + expect(isTrustedApiRequest(request({ host: 'harness.internal:443', origin: 'http://harness.internal' }), ['harness.internal'])).toBe(false) + expect(isTrustedApiRequest(request({ host: 'harness.internal:443', origin: 'https://evil.example' }), ['harness.internal'])).toBe(false) + // Cross-site labelling outranks a purely spelling-level match. + expect(isTrustedApiRequest(request({ + host: 'harness.internal:443', + origin: 'https://harness.internal', + 'sec-fetch-site': 'cross-site', + }), ['harness.internal'])).toBe(false) + // Only http(s) names this listener; opaque and other schemes never compare as an authority. + for (const origin of ['ftp://harness.internal', 'file://harness.internal', 'ws://harness.internal', 'null']) { + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin }), ['harness.internal']), origin).toBe(false) + } + }) + it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => { const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' } expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true) diff --git a/packages/client/connection/tests/browser-auth.host.spec.ts b/packages/client/connection/tests/browser-auth.host.spec.ts index c271aa313b..f108023c69 100644 --- a/packages/client/connection/tests/browser-auth.host.spec.ts +++ b/packages/client/connection/tests/browser-auth.host.spec.ts @@ -76,11 +76,12 @@ function request(url: string, authority = '127.0.0.1:3080', init?: { function exchange( auth: BrowserAuth, authority = '127.0.0.1:3080', + secure = false, ): { cookie: string; launchUrl: string; state: ResponseState } { const launchUrl = auth.authenticatedUrl(`http://${authority}`) const target = new URL(launchUrl) const res = response() - expect(auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false) + expect(auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value, secure)).toBe(false) const setCookie = res.state.headers?.['set-cookie'] if (setCookie === undefined) throw new Error('token exchange did not set a cookie') return { cookie: setCookie.split(';', 1)[0]!, launchUrl, state: res.state } @@ -140,6 +141,34 @@ describe('BrowserAuth', () => { }) }) + it('keeps HTTPS port 443 distinct from HTTP port 80 with one signing secret', async () => { + const auth = await createAuth(new RecordCredentials()) + const plain = exchange(auth, 'harness.example') + const tls = exchange(auth, 'harness.example', true) + expect(auth.isAuthenticated(request('/', 'harness.example', { cookie: plain.cookie }), true)).toBe(false) + expect(auth.isAuthenticated(request('/', 'harness.example', { cookie: tls.cookie }), true)).toBe(true) + expect(auth.isAuthenticated(request('/', 'harness.example:443', { cookie: tls.cookie }), true)).toBe(true) + expect(auth.isAuthenticated(request('/', 'harness.example', { cookie: tls.cookie }))).toBe(false) + }) + + it('rejects HTTP cookie replay after enabling TLS on the same authority', async () => { + const store = new RecordCredentials() + const authority = 'harness.example:3080' + const plain = exchange(await createAuth(store), authority) + const restarted = await createAuth(store) + const denied = response() + expect(restarted.authorizeIndex(request('/', authority, { cookie: plain.cookie }), denied.value, true)).toBe(false) + expect(denied.state.status).toBe(401) + + const tls = exchange(restarted, authority, true) + expect(tls.state.headers?.['set-cookie']).toContain('; Secure') + expect(restarted.isAuthenticated(request('/', authority, { cookie: tls.cookie }), true)).toBe(true) + expect(restarted.isAuthenticated(request('/', authority, { cookie: tls.cookie }))).toBe(false) + const tlsName = tls.cookie.slice(0, tls.cookie.indexOf('=')) + const plainValue = plain.cookie.slice(plain.cookie.indexOf('=') + 1) + expect(restarted.isAuthenticated(request('/', authority, { cookie: `${tlsName}=${plainValue}` }), true)).toBe(false) + }) + it('preserves the caller authority and mount while adding only this process token', async () => { const auth = await createAuth(new RecordCredentials()) const mounted = new URL(auth.authenticatedUrl('https://gateway.example/tools/dsh/')) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 5642bff493..9a16fe0c43 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -25,8 +25,12 @@ import { provideBrowserCredentials } from './browser-credentials.ts' function fakeHttpServer( routes: WebRoute[], upgrades: WebUpgradeRoute[], -): Pick { + host = '127.0.0.1', + protocol: WebServer['protocol'] = 'http:', +): Pick { return { + host, + protocol, register(route) { if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) { throw new Error(`duplicate route ${route.path}`) @@ -90,7 +94,7 @@ function fakeResponse(): { return { response, state } } -async function mounted(config?: ConnectionConfig): Promise<{ +async function mounted(config?: ConnectionConfig, protocol: WebServer['protocol'] = 'http:'): Promise<{ ctx: Context routes: WebRoute[] upgrades: WebUpgradeRoute[] @@ -101,7 +105,7 @@ async function mounted(config?: ConnectionConfig): Promise<{ const routes: WebRoute[] = [] const upgrades: WebUpgradeRoute[] = [] provideBrowserCredentials(ctx) - ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) + ctx.provide('webServer', fakeHttpServer(routes, upgrades, '127.0.0.1', protocol) as WebServer) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() return { @@ -194,6 +198,65 @@ describe('connection node half', () => { await fiber.dispose() }) + it('accepts the bind address of a web carrier attached after Connection', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + const upgrades: WebUpgradeRoute[] = [] + provideBrowserCredentials(ctx) + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) + try { + await fiber.await() + const connection = ctx.get('connection') as HostConnectionHandle + const web = ctx.plugin((webCtx) => { + webCtx.provide('webServer', fakeHttpServer(routes, upgrades, '10.1.2.3') as WebServer) + }) + try { + await web.await() + await expect.poll(() => routes.find(route => route.path === API_PATH)).toBeDefined() + const bound = browserCookie(connection, '10.1.2.3:3080') + expect(connection.requestRejection(fakeRequest({ host: '10.1.2.3:3080', cookie: bound }))).toBeUndefined() + } finally { + await web.dispose() + } + } finally { + await fiber.dispose() + } + }) + + it('uses the TLS default port for markerless exact-authority grants', async () => { + const { connection, dispose } = await mounted({ trustedHosts: ['tls.example:443', 'plain.example:80'] }, 'https:') + try { + expect(connection.requestRejection(fakeRequest({ host: 'tls.example' }))).toBe(401) + expect(connection.requestRejection(fakeRequest({ host: 'plain.example' }))).toBe(403) + expect(connection.requestRejection(fakeRequest({ host: 'tls.example:444' }))).toBe(403) + } finally { + await dispose() + } + }) + + it('marks the minted browser cookie Secure from the mounted listener protocol', async () => { + for (const [protocol, secure] of [['http:', false], ['https:', true]] as const) { + const { connection, dispose } = await mounted(undefined, protocol) + try { + const url = new URL(connection.authenticatedUrl('http://127.0.0.1:3080')) + const exchanged = fakeResponse() + connection.authorizeIndex( + fakeRequest({ host: '127.0.0.1:3080', 'x-forwarded-proto': secure ? 'http' : 'https' }, `${url.pathname}${url.search}`), + exchanged.response, + ) + const setCookie = exchanged.state.headers?.['set-cookie'] + expect(setCookie?.endsWith('; Secure'), protocol).toBe(secure) + if (setCookie === undefined) throw new Error('browser token exchange did not set a cookie') + expect(connection.requestRejection(fakeRequest({ + host: '127.0.0.1:3080', + cookie: setCookie.split(';', 1)[0]!, + }))).toBeUndefined() + } finally { + await dispose() + } + } + }) + it('injects validated browser recovery timing and withdraws it on disposal', async () => { const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } }) try { @@ -223,6 +286,22 @@ describe('connection node half', () => { expect(ctx.get('connection')).toBeUndefined() }) + it('reports allowsRemoteAuthorities from validated trustedHosts only', async () => { + const cases: { trustedHosts: string[]; expected: boolean }[] = [ + { trustedHosts: ['127.0.0.1', 'localhost', '[::1]'], expected: false }, + { trustedHosts: ['harness.example'], expected: true }, + { trustedHosts: ['127.0.0.1', 'harness.example'], expected: true }, + ] + for (const { trustedHosts, expected } of cases) { + const deployment = await mounted({ trustedHosts }) + try { + expect(deployment.connection.allowsRemoteAuthorities, trustedHosts.join(',')).toBe(expected) + } finally { + await deployment.dispose() + } + } + }) + it('reserves enough default carrier capacity for the 200 MiB image batch', () => { expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBe(300 * 1024 * 1024) expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBeGreaterThan(Math.ceil(200 * 1024 * 1024 * 4 / 3) + 1024 * 1024) @@ -311,8 +390,6 @@ describe('connection node half', () => { cookie: browserCookie(connection, '127.0.0.1:3080'), }), loopback.response) expect(loopback.state.status).toBe(404) - // An all-interfaces composition derives port-less LAN IP literals, which - // pass markerless curl on any port. const lan = fakeResponse() await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080', diff --git a/packages/client/connection/tsdown.config.ts b/packages/client/connection/tsdown.config.ts index 69cd7ce36d..7aa03cebc1 100644 --- a/packages/client/connection/tsdown.config.ts +++ b/packages/client/connection/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/file-upload/locale/en.json b/packages/client/file-upload/locale/en.json new file mode 100644 index 0000000000..1a2719ee6c --- /dev/null +++ b/packages/client/file-upload/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "file-upload" + } +} diff --git a/packages/client/file-upload/locale/zh.json b/packages/client/file-upload/locale/zh.json new file mode 100644 index 0000000000..5a330e6b60 --- /dev/null +++ b/packages/client/file-upload/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文件上传", + "description": "将浏览器中的文件上传到当前会话,供消息和工具使用。" + } +} diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index eecaa28d28..7c5f43b873 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -35,7 +35,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -52,6 +53,7 @@ }, "license": "MIT", "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -62,7 +64,7 @@ "lib/typert.remote-client.d.ts" ], "dependencies": { - "zod": "^4.4.3", + "zod": "4.4.3", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*" }, diff --git a/packages/client/file-upload/tsdown.config.ts b/packages/client/file-upload/tsdown.config.ts index 97292cd3cc..7aa03cebc1 100644 --- a/packages/client/file-upload/tsdown.config.ts +++ b/packages/client/file-upload/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-file-upload', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/hmr/locale/en.json b/packages/client/hmr/locale/en.json new file mode 100644 index 0000000000..47f242be8c --- /dev/null +++ b/packages/client/hmr/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "hmr" + } +} diff --git a/packages/client/hmr/locale/zh.json b/packages/client/hmr/locale/zh.json new file mode 100644 index 0000000000..28262e2f7c --- /dev/null +++ b/packages/client/hmr/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Web 客户端热更新", + "description": "同步浏览器中的插件,并在代码重建后更新相关界面。" + } +} diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 373c176123..3fd0ca2c4d 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Web client graph synchronization and rebuilt-bundle reload transport", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -48,6 +49,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/hmr/tsdown.config.ts b/packages/client/hmr/tsdown.config.ts index 69b96b4229..7aa03cebc1 100644 --- a/packages/client/hmr/tsdown.config.ts +++ b/packages/client/hmr/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-hmr', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 4b363e6971..2e2736659e 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -9,8 +9,8 @@ en: 9365090d78467dea zh: 74b6fbb9769d795f /deepseek-ai-dsh-client-locale/summary: - en: a548ff3095e8d7d8 - zh: fec6ad47291018f8 + en: d8cc5ae25160ca20 + zh: e8fc2b9fb663cdee /deepseek-ai-dsh-client-locale/table-of-contents: en: d152484eb41ac6b4 zh: bda879a892f240f0 @@ -30,8 +30,8 @@ en: aa4e9c70523339cc zh: 3359eddc4e9ef15e /deepseek-ai-dsh-client-locale/use-this-package/what-the-host-half-does: - en: 8fa91333f7fc3387 - zh: 789a9cbb3eace3b0 + en: 784035ca4b4838b8 + zh: 4a258795f408528f /deepseek-ai-dsh-client-locale/understand-the-implementation: en: 288d8db39f9acfc5 zh: d65cf2ffae8ad35c @@ -39,8 +39,8 @@ en: c2e0ab8bb6bcb26a zh: 508dec4db2c0fde6 /deepseek-ai-dsh-client-locale/understand-the-implementation/preference-resolution: - en: 4bc9dffa4629f537 - zh: f51633c658b8d25b + en: d602d957654641c6 + zh: 2db822b746a6471d /deepseek-ai-dsh-client-locale/understand-the-implementation/dictionary-lookup: en: f8dcc4035830e738 zh: 8452ac63ee148c61 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 930c715a47..8c52703748 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-client-locale` to switch the web GUI between the shipped English and Chinese locales or languages added by client plugins. User selections take effect immediately; loopback pages persist them in `$DSH_HOME/cordis.patch.yml`, while non-loopback pages keep them only for the current process. New browsers use the first supported language requested by the browser until an allowed stored preference arrives. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload. +Use `dsh-client-locale` to switch the web GUI between the shipped English and Chinese locales or languages added by client plugins. User selections take effect immediately; every authenticated browser persists them as `locale.preference` in `$DSH_HOME/cordis.patch.yml` through the Host settings service. New browsers use the first supported language requested by the browser until the Host read delivers a stored preference. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload. ## Table of Contents @@ -67,7 +67,7 @@ An external id is a non-empty ASCII BCP 47-style tag. Its fallback must already ### What the Host half does -The Host persists the preference through the settings service on loopback pages. The Client deliberately withholds that settings scope on non-loopback pages, so their locale selection remains process-local even though Connection authenticates every API method. +Every authenticated browser persists the preference through the Host settings service under the shared [settings scope policy](../ui-settings/README.md). ----- @@ -85,7 +85,7 @@ One `LocaleRuntime` owns the preference and the dictionary registry, and is itse ### Preference resolution -The provisional locale comes from the browser (`navigator.languages` matched by full tag and then primary subtag, English as the fallback), standing in until the allowed Host-backed settings scope delivers its stored preference. The Host read runs after plugin activation so an unavailable or withheld settings scope cannot block the page, and the result replaces the provisional value live. A stored external locale waits for its definition to register. `setLocale` is the only write entry; it persists even when the id already matches the active locale, because the active value may be provisional and must survive a different browser sharing the same home. +The provisional locale comes from the browser (`navigator.languages` matched by full tag and then primary subtag, English as the fallback), standing in until the Host settings scope delivers its stored preference. The Host read runs after plugin activation so an unavailable settings scope cannot block the page, and the result replaces the provisional value live. A stored external locale waits for its definition to register. `setLocale` is the only write entry; it persists even when the id already matches the active locale, because the active value may be provisional and must survive a different browser sharing the same home. ### Dictionary lookup diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 6298fac88f..0527984591 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-client-locale` 可在 web GUI 中切换内置的英文和中文 locale,或 client 插件添加的语言。用户选择会立即生效;loopback 页面把选择持久化到 `$DSH_HOME/cordis.patch.yml`,非 loopback 页面则只为当前进程保留选择。全新浏览器会使用浏览器请求的第一个受支持语言,直到允许读取的已存储偏好到达。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。 +使用 `dsh-client-locale` 可在 web GUI 中切换内置的英文和中文 locale,或 client 插件添加的语言。用户选择会立即生效;每个已认证浏览器都通过 Host settings 服务把选择以 `locale.preference` 持久化到 `$DSH_HOME/cordis.patch.yml`。全新浏览器会使用浏览器请求的第一个受支持语言,直到 Host 读取送达已存储偏好。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。 ## 目录 @@ -67,7 +67,7 @@ export function apply(ctx) { ### Host 半侧做什么 -Host 通过 settings 服务为 loopback 页面持久化偏好。Client 会刻意拒绝非 loopback 页面使用该 settings scope,因此即使 Connection 认证所有 API 方法,它们的 locale 选择仍只存在于进程内。 +每个已认证浏览器都遵循共享的 [settings scope 策略](../ui-settings/README.zh.md),通过 Host settings 服务持久化偏好。 ----- @@ -85,7 +85,7 @@ Host 通过 settings 服务为 loopback 页面持久化偏好。Client 会刻意 ### 偏好解析 -临时 locale 来自浏览器(`navigator.languages` 先按完整标签、再按主语言子标签匹配,以英文作为回退),在允许使用的 Host-backed settings scope 送达其存储偏好之前生效。Host 读取在插件激活后运行,因此 settings scope 不可用或被拒绝都不会阻塞页面,结果会实时替换临时值。已存储的外部 locale 会等待其定义注册。`setLocale` 是唯一写入入口;即使 id 已与生效中的 locale 匹配也会持久化,因为生效中的值可能是临时的,必须能供共享同一 home 的其他浏览器继续使用。 +临时 locale 来自浏览器(`navigator.languages` 先按完整标签、再按主语言子标签匹配,以英文作为回退),在 Host settings scope 送达其存储偏好之前生效。Host 读取在插件激活后运行,因此 settings scope 不可用不会阻塞页面,结果会实时替换临时值。已存储的外部 locale 会等待其定义注册。`setLocale` 是唯一写入入口;即使 id 已与生效中的 locale 匹配也会持久化,因为生效中的值可能是临时的,必须能在共享同一 home 的其他浏览器上存活。 ### 字典查找 diff --git a/packages/client/locale/locale/en.json b/packages/client/locale/locale/en.json new file mode 100644 index 0000000000..9f4e0d1c02 --- /dev/null +++ b/packages/client/locale/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "locale" + } +} diff --git a/packages/client/locale/locale/zh.json b/packages/client/locale/locale/zh.json new file mode 100644 index 0000000000..8fa3c8e462 --- /dev/null +++ b/packages/client/locale/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "界面语言", + "description": "管理界面语言偏好和各插件的翻译文案。" + } +} diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index a1577330a2..6cc7ea6e2a 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -51,7 +52,6 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*" @@ -60,6 +60,7 @@ "@deepseek-ai/schemastery": "workspace:~" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/locale/tsdown.config.ts b/packages/client/locale/tsdown.config.ts index 8babf9f1eb..7aa03cebc1 100644 --- a/packages/client/locale/tsdown.config.ts +++ b/packages/client/locale/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 4ab13cc6e9..ebb8ecbb3a 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -27,8 +27,8 @@ en: 45740a56e931d094 zh: b3362af77c804459 /deepseek-ai-dsh-client-modules/use-this-package/sharing-modules: - en: 0aaf0693db8bb203 - zh: 9f4738cdc6b55c26 + en: 1778b5a63ecde5e8 + zh: 537b4f8c755af8ad /deepseek-ai-dsh-client-modules/use-this-package/build-requirements: en: b6a4d3b901c89efe zh: 615b5f3eec41f281 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 3388eb2ae4..8224ab0ac7 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -43,7 +43,7 @@ An open Web page follows the Host's complete module graph through the HMR transp ### Sharing modules -The shell seeds a frozen module table (`PLATFORM_MODULES`: React, Cordis, and static UI libraries); every dynamic bundle resolves its externals against exactly that baseline. `dsh.client.external` adds only exact non-baseline requests, each answered by the dynamic package row it names or an exact static-table key. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles. +The shell seeds a frozen module table ([`PLATFORM_MODULES`](../web/README.md#the-shared-module-table)); every dynamic bundle resolves its externals against exactly that baseline. `dsh.client.external` adds only exact non-baseline requests, each answered by the dynamic package row it names or an exact static-table key. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles. ### Build requirements diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index 3cbc600ee5..c972fe38d4 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -43,7 +43,7 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启 ### 共享模块 -外壳初始化一张冻结的模块表(`PLATFORM_MODULES`:React、Cordis 与静态 UI 库);每个动态 bundle 都精确针对该基座解析其 external。`dsh.client.external` 只添加基座之外的精确请求;系统会将每个请求解析到其指定的动态包 row 或完全匹配的静态表键。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求与同步请求环。 +外壳初始化一张冻结的模块表([`PLATFORM_MODULES`](../web/README.zh.md#the-shared-module-table));每个动态 bundle 都精确针对该基座解析其 external。`dsh.client.external` 只添加基座之外的精确请求;系统会将每个请求解析到其指定的动态包 row 或完全匹配的静态表键。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求与同步请求环。 ### 构建要求 diff --git a/packages/client/modules/locale/en.json b/packages/client/modules/locale/en.json new file mode 100644 index 0000000000..cd77943573 --- /dev/null +++ b/packages/client/modules/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "modules" + } +} diff --git a/packages/client/modules/locale/zh.json b/packages/client/modules/locale/zh.json new file mode 100644 index 0000000000..d24fc0c1fd --- /dev/null +++ b/packages/client/modules/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "客户端模块加载", + "description": "管理浏览器插件模块及其加载依赖。" + } +} diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 497adf5366..c9d0440c04 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -45,6 +46,7 @@ "@deepseek-ai/dsh-client-store": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/modules/tsdown.config.ts b/packages/client/modules/tsdown.config.ts index 06db107f32..3a0694bdf9 100644 --- a/packages/client/modules/tsdown.config.ts +++ b/packages/client/modules/tsdown.config.ts @@ -1,6 +1,6 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-modules', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/product-analytics/locale/en.json b/packages/client/product-analytics/locale/en.json new file mode 100644 index 0000000000..0351b24e9f --- /dev/null +++ b/packages/client/product-analytics/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "product-analytics" + } +} diff --git a/packages/client/product-analytics/locale/zh.json b/packages/client/product-analytics/locale/zh.json new file mode 100644 index 0000000000..6740088981 --- /dev/null +++ b/packages/client/product-analytics/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "桌面端使用统计", + "description": "采集桌面端指定交互事件,并提交给产品统计服务。" + } +} diff --git a/packages/client/product-analytics/package.json b/packages/client/product-analytics/package.json index a7a8b4cddc..a8240ccc6b 100644 --- a/packages/client/product-analytics/package.json +++ b/packages/client/product-analytics/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-product-analytics", "description": "Desktop product event collection and authenticated Host reporting", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -34,7 +34,8 @@ }, "./types": { "types": "./lib/types/events.d.ts" - } + }, + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -66,6 +67,7 @@ "@deepseek-ai/dsh-otel": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts", @@ -77,6 +79,6 @@ "dependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" } } diff --git a/packages/client/product-analytics/tsdown.config.ts b/packages/client/product-analytics/tsdown.config.ts index 4479f52fe2..620261533c 100644 --- a/packages/client/product-analytics/tsdown.config.ts +++ b/packages/client/product-analytics/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-product-analytics', ['lib/types/index.js'], { hostPhase: true }) +export default clientBundle(import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }) diff --git a/packages/client/resources/locale/en.json b/packages/client/resources/locale/en.json new file mode 100644 index 0000000000..8350a2a390 --- /dev/null +++ b/packages/client/resources/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "resources" + } +} diff --git a/packages/client/resources/locale/zh.json b/packages/client/resources/locale/zh.json new file mode 100644 index 0000000000..4a9685490b --- /dev/null +++ b/packages/client/resources/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "客户端资源服务", + "description": "将文件等资源地址解析为可供界面读取的内容。" + } +} diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index 531d64def9..1790c6ac68 100644 --- a/packages/client/resources/package.json +++ b/packages/client/resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-resources", "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/resources/tsdown.config.ts b/packages/client/resources/tsdown.config.ts index a6e81e749c..7aa03cebc1 100644 --- a/packages/client/resources/tsdown.config.ts +++ b/packages/client/resources/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-resources', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/shortcuts/locale/en.json b/packages/client/shortcuts/locale/en.json new file mode 100644 index 0000000000..b79351a738 --- /dev/null +++ b/packages/client/shortcuts/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "shortcuts" + } +} diff --git a/packages/client/shortcuts/locale/zh.json b/packages/client/shortcuts/locale/zh.json new file mode 100644 index 0000000000..9bcdbae433 --- /dev/null +++ b/packages/client/shortcuts/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "快捷键服务", + "description": "注册应用快捷键,并将按键操作分发到对应功能。" + } +} diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json index 76f34297ec..8bc13120c6 100644 --- a/packages/client/shortcuts/package.json +++ b/packages/client/shortcuts/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-shortcuts", "description": "Application keyboard command registry and physical-key routing", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -27,7 +27,8 @@ "./protocol": { "types": "./lib/types/protocol.d.ts", "default": "./lib/protocol.js" - } + }, + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/protocol.js", diff --git a/packages/client/shortcuts/tsdown.config.ts b/packages/client/shortcuts/tsdown.config.ts index 4f32cd61c0..bae160bb21 100644 --- a/packages/client/shortcuts/tsdown.config.ts +++ b/packages/client/shortcuts/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-shortcuts', + import.meta.dirname, ['lib/types/index.js', 'lib/types/protocol.js'], { hostPhase: true }, ) diff --git a/packages/client/store/package.json b/packages/client/store/package.json index e6284b5e8c..a0b08df37f 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/tsdown.config.ts b/packages/client/store/tsdown.config.ts index b505d0388d..a6264e0b21 100644 --- a/packages/client/store/tsdown.config.ts +++ b/packages/client/store/tsdown.config.ts @@ -1,6 +1,6 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( - '@deepseek-ai/dsh-client-store', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 2e8c06b18e..888519a890 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -11,7 +11,7 @@ * inputs, including origins recorded by chained source maps. */ import { readFile, stat, utimes } from 'node:fs/promises' -import { existsSync, globSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { createRequire, isBuiltin } from 'node:module' import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path' import { fileURLToPath } from 'node:url' @@ -100,22 +100,25 @@ function browserSourcePath(source: string, sourcemapPath: string): string { * the package without lib/index.js and the host Loader cannot import its node * half. The Client build consumes `lib/types` and chains those tsc maps, with * original source content, into the standalone plugin map. - * @param id - plugin id (package name), stamped into the __ModuleLoader__.load - * handoff and onto the injected style tags. + * @param packageDir - the package's own directory, normally the config's + * `import.meta.dirname`. Its `package.json` name is the plugin id stamped into + * the __ModuleLoader__.load handoff and onto the injected style tags. * @param libEntry - node-half entries. * @param options - phase placement, lib overrides, companion Node configs, and optional per-file Client banner. * @returns ENV-selected tsdown config for the current build face. + * @throws {Error} when `/package.json` is unreadable or declares no name. */ export function clientBundle( - id: string, + packageDir: string, libEntry: readonly string[], options: ClientBundleOptions = {}, ): BuildFaceConfig { - const lib = clientLibraryConfig(id, libEntry, options.lib) + const manifest = readPackageManifest(packageDir) + const lib = clientLibraryConfig(manifest, libEntry, options.lib) return ({ env }) => { const face = buildFace(env?.DSH_BUILD_FACE) const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js' - const client = clientConfig(id, clientEntry, options.clientBanner) + const client = clientConfig(manifest, clientEntry, options.clientBanner) const node = [lib, ...(options.companions ?? [])] if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD] if (face === 'client') { @@ -147,13 +150,16 @@ export function clientBundle( * 4. stylesheets ship with the package: a relative `.css` import survives as a * relative external and the sheet is emitted under `lib/` at its * `src`-relative path, so vite stays the only owner of class hashing. - * @param id - package name, used in tsdown diagnostics. + * @param packageDir - the package's own directory, normally the config's + * `import.meta.dirname`; its `package.json` name is used in tsdown diagnostics. * @param libEntry - emitted JavaScript entries consumed from `lib/types`, one * bundle each: a multi-entry build would emit a hash-named shared chunk that * the exact `files` list cannot publish. * @returns ENV-selected tsdown config for the Client build face. + * @throws {Error} when `/package.json` is unreadable or declares no name. */ -export function staticLinked(id: string, libEntry: readonly string[]): BuildFaceConfig { +export function staticLinked(packageDir: string, libEntry: readonly string[]): BuildFaceConfig { + const id = readPackageManifest(packageDir).name // Each entry names its own output file, so two entries with the same basename // would overwrite one artifact instead of emitting two. const names = new Set(libEntry.map(entry => basename(entry, '.js'))) @@ -177,12 +183,14 @@ export function isStaticLinkedConfig(configs: readonly UserConfig[]): boolean { /** * Build a Client-only Node library during the Client pass. - * @param id - Package name used in tsdown diagnostics. + * @param packageDir - The package's own directory, normally the config's + * `import.meta.dirname`; its `package.json` name is used in tsdown diagnostics. * @param libEntry - Emitted JavaScript entries consumed from `lib/types`. * @returns ENV-selected tsdown config for the Client build face. + * @throws {Error} when `/package.json` is unreadable or declares no name. */ -export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig { - const lib = clientLibraryConfig(id, libEntry) +export function clientLibrary(packageDir: string, libEntry: readonly string[]): BuildFaceConfig { + const lib = clientLibraryConfig(readPackageManifest(packageDir), libEntry) return clientOnly([lib]) } @@ -218,14 +226,15 @@ function buildFace(value: unknown): BuildFace { } function clientLibraryConfig( - id: string, + manifest: PackageManifest, libEntry: readonly string[], overrides: UserConfig = {}, ): UserConfig { + let externals: readonly RegExp[] | undefined const isProductionDependency = (specifier: string): boolean => - matchesSpecifier(productionExternals(id), specifier) + matchesSpecifier(externals ??= productionExternals(manifest), specifier) return { - name: id, + name: manifest.name, entry: [...libEntry], outDir: 'lib', format: ['esm'], @@ -330,9 +339,10 @@ function stylesheetAsset(source: string, importer: string): { readonly file: str return { file, fileName: file.slice(boundary + SOURCE_MARKER.length).split(sep).join('/') } } -/** The manifest fields the build faces read to state their own module edges. */ -interface WorkspaceManifest { - readonly name?: string +/** The manifest fields the build faces read to name the package and state its own module edges. */ +interface PackageManifest { + /** Package name: the tsdown config name, the Client loader id, and the diagnostic subject. */ + readonly name: string /** Sections a real install materializes on disk next to the built package. */ readonly dependencies?: Record readonly peerDependencies?: Record @@ -340,52 +350,44 @@ interface WorkspaceManifest { readonly dsh?: { readonly client?: { readonly external?: unknown } } } -const manifestCache = new Map() -const productionExternalCache = new Map() -const clientExternalCache = new Map>() - /** - * Read one workspace package's manifest. Located by package name rather than by - * cwd, because tsdown evaluates every package config with the repository root as - * `process.cwd()` during a workspace build. Callers read it on the first - * resolveId of a build, not while a config is built, so selecting a build face - * never touches a manifest. - * @param id - package name, as spelled at the preset call site. + * Read the manifest of the package whose config calls a preset. The caller + * passes its directory because tsdown evaluates every package config with the + * repository root as `process.cwd()` during a workspace build. + * @param packageDir - the package's own directory. * @returns the parsed manifest. - * @throws {Error} when no workspace package declares that name. + * @throws {Error} when `package.json` is unreadable, is not JSON, or declares no string name. */ -function workspaceManifest(id: string): WorkspaceManifest { - const cached = manifestCache.get(id) - if (cached !== undefined) return cached - for (const manifestPath of globSync('packages/*/*/package.json', { cwd: REPOSITORY_ROOT })) { - const manifest = JSON.parse( - readFileSync(resolvePath(REPOSITORY_ROOT, manifestPath), 'utf8'), - ) as WorkspaceManifest - if (manifest.name !== id) continue - manifestCache.set(id, manifest) - return manifest +function readPackageManifest(packageDir: string): PackageManifest { + const manifestPath = resolvePath(packageDir, 'package.json') + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) + } catch (error) { + throw new Error( + `tsdown: cannot read ${manifestPath}; pass the package directory (the import.meta.dirname of its tsdown.config.ts)`, + { cause: error }, + ) } - throw new Error(`tsdown: no packages/*/*/package.json declares the name ${id}`) + if (typeof parsed !== 'object' || parsed === null || !('name' in parsed) || typeof parsed.name !== 'string') { + throw new Error(`tsdown: ${manifestPath} declares no string package name`) + } + return parsed as PackageManifest } /** * External patterns for one package's Node half: its own production sections, * subpaths included. - * @param id - package name, as spelled at the preset call site. + * @param manifest - the package's own manifest. * @returns one `^name(/|$)` pattern per production dependency, name-sorted. */ -function productionExternals(id: string): readonly RegExp[] { - const cached = productionExternalCache.get(id) - if (cached !== undefined) return cached - const manifest = workspaceManifest(id) +function productionExternals(manifest: PackageManifest): readonly RegExp[] { const names = new Set([ ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {}), ]) - const patterns = [...names].sort().map(name => new RegExp(`^${escapeSpecifier(name)}(/|$)`)) - productionExternalCache.set(id, patterns) - return patterns + return [...names].sort().map(name => new RegExp(`^${escapeSpecifier(name)}(/|$)`)) } /** @@ -408,19 +410,15 @@ export function requestedExternals( * Module-table specifiers one package requests. The shell baseline is implicit * for every dynamic bundle; `dsh.client.external` only adds package-specific * dynamic rows or subpaths. - * @param id - package name, as spelled at the preset call site. + * @param manifest - the package's own manifest, whose `dsh.client` declaration adds requests. * @returns the baseline plus the package's explicit requests. */ -function clientExternals(id: string): ReadonlySet { - const cached = clientExternalCache.get(id) - if (cached !== undefined) return cached - const externals = new Set([ +function clientExternals(manifest: PackageManifest): ReadonlySet { + return new Set([ ...PLATFORM_MODULES, ...PRELOADED_CLIENT_EXTERNALS, - ...requestedExternals(id, workspaceManifest(id).dsh?.client ?? {}), + ...requestedExternals(manifest.name, manifest.dsh?.client ?? {}), ]) - clientExternalCache.set(id, externals) - return externals } /** Escape a package name for literal use inside a RegExp source. */ @@ -469,8 +467,10 @@ function asyncChunkRequirePlugin(): TsdownPlugin { } } -function clientConfig(id: string, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig { - const isRequested = (specifier: string): boolean => clientExternals(id).has(specifier) +function clientConfig(manifest: PackageManifest, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig { + const id = manifest.name + let requested: ReadonlySet | undefined + const isRequested = (specifier: string): boolean => (requested ??= clientExternals(manifest)).has(specifier) const isolation = clientInputIsolation(id) return { name: `${id}/client`, @@ -490,7 +490,7 @@ function clientConfig(id: string, entry: string, clientBanner?: (fileName: strin deps: { neverBundle: isRequested, // Anything NOT requested from the loader module table must inline - // (wire/type layers, zod, clsx — every non-shared dep). A require() the + // (wire/type layers, clsx — every non-shared dep). A require() the // table cannot answer is a guaranteed runtime throw, so the rule is the // package's own request list: requested specifiers stay imports, // everything else is bundled. diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 7181942309..889f6327b3 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: b3ad7867f23dad1c /deepseek-ai-dsh-client-ui-agent-preset/use-this-package: - en: d616984e840f036e - zh: 3e7c97c7d10669eb + en: a21296842d6e18da + zh: 75f3a6a79691884f /deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation: - en: b3221ef8bc6d8096 - zh: 68cb05566249da10 + en: a90bf7539dbce636 + zh: d77c1fecac2c6a4b /deepseek-ai-dsh-client-ui-agent-preset/further-exploration: en: bbae8d79844bc481 zh: 4302107fd1f460e6 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 09df3e46f3..4108cc9231 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -29,7 +29,7 @@ Settings shows the built-in and custom card groups with default highlighting and With Coding Tools off in General Settings, the new-session picker and Settings roster offer Standard, Creator, and custom presets; turning them on also offers PTC and Minimal. Default selection and the Creator entry remain available in both states. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. -Turning Coding Tools off clears all unapplied choices through the existing Developer tools subscription. Once the off value is accepted, a host-backed connection resets a saved built-in PTC or Minimal default to Standard. Saved Standard, Creator, and custom defaults (including named PTC or Minimal overrides) stay unchanged. The user can select an available preset again after turning Coding Tools off. Existing sessions keep their selected mode; New Session uses the current default. An existing mode's label remains visible even when that mode is absent from the menu. Turning Coding Tools back on does not restore the old default or cleared choice. A remote connection using in-memory settings does not rewrite the Host's default. If Standard is missing or broken, the default remains unchanged and an error is shown. +Turning Coding Tools off clears all unapplied choices through the existing Developer tools subscription. Once the off value is accepted, every authenticated browser resets a saved built-in PTC or Minimal default to Standard. Saved Standard, Creator, and custom defaults (including named PTC or Minimal overrides) stay unchanged. The user can select an available preset again after turning Coding Tools off. Existing sessions keep their selected mode; New Session uses the current default. An existing mode's label remains visible even when that mode is absent from the menu. Turning Coding Tools back on does not restore the old default or cleared choice. If Standard is missing or broken, the default remains unchanged and an error is shown. Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. @@ -45,7 +45,7 @@ Entering Creator selects `cordis` for the receiving blank Session without changi `agentPresets/list` supplies the roster and marks the current default, and `agentPresets/read` one declaration's YAML for the viewer; default changes write the `agent-preset-registry` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster. -`CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController` and opens the task through `uiWorkspace.startSession` with explicit draft preservation; it can reuse a blank Session and applies the staged choice when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component. +`CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController` and opens the task through `uiWorkspace.startSession` with explicit draft preservation and `agentPreset: 'cordis'`, so the Host composes the reused blank or new Session under Creator; without a target Workspace, the staged choice applies when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component.
diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 7514fc9953..256cc81182 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" 关闭通用设置中的代码工作工具后,新会话选择器和设置页列表提供标准、创造和自定义预设;开启后还提供 PTC 和极简模式。两种状态下均保留默认值选择和创造入口。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。 -关闭代码工作工具时,沿用现有 Developer tools 订阅清空所有尚未应用的选择。关闭状态被接受后,使用 Host 持久化设置的连接会将已保存的内置 PTC 或极简默认值回退为标准模式。已保存的标准、创造和自定义默认值(包括有名称的 PTC 或极简覆盖项)保持不变。关闭后用户仍可重新选择可用预设。已有会话保留所选模式;新会话使用当前默认值。即使已有会话的模式不在菜单中,其标签仍会显示。重新开启代码工作工具不会恢复原默认值或已清除的选择。使用内存设置的远程连接不会改写 Host 的默认值。标准模式缺失或损坏时,默认值保持不变,并显示错误。 +关闭代码工作工具时,沿用现有 Developer tools 订阅清空所有尚未应用的选择。关闭状态被接受后,每个已认证浏览器都会将已保存的内置 PTC 或极简默认值回退为标准模式。已保存的标准、创造和自定义默认值(包括有名称的 PTC 或极简覆盖项)保持不变。关闭后用户仍可重新选择可用预设。已有会话保留所选模式;新会话使用当前默认值。即使已有会话的模式不在菜单中,其标签仍会显示。重新开启代码工作工具不会恢复原默认值或已清除的选择。标准模式缺失或损坏时,默认值保持不变,并显示错误。 已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 @@ -45,7 +45,7 @@ kind: "package-reference" `agentPresets/list` 提供列表并标记当前默认值,`agentPresets/read` 为查看器提供一条声明的 YAML;默认值的修改写入 `agent-preset-registry` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。 -`CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`,再通过显式保留草稿的 `uiWorkspace.startSession` 调用打开任务;它可以复用空白 Session,并在 Session 绑定可用时应用暂存选择。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。 +`CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`,再通过显式保留草稿并传入 `agentPreset: 'cordis'` 的 `uiWorkspace.startSession` 调用打开任务,由 Host 以创造模式组合被复用的空白 Session 或新 Session;没有目标工作区时,暂存选择在 Session 绑定可用时应用。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。
diff --git a/packages/client/ui-agent-preset/locale/en.json b/packages/client/ui-agent-preset/locale/en.json new file mode 100644 index 0000000000..6cb010026a --- /dev/null +++ b/packages/client/ui-agent-preset/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-agent-preset" + } +} diff --git a/packages/client/ui-agent-preset/locale/zh.json b/packages/client/ui-agent-preset/locale/zh.json new file mode 100644 index 0000000000..4e8dd6b04d --- /dev/null +++ b/packages/client/ui-agent-preset/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Agent 预设界面", + "description": "选择当前及新会话的 Agent 预设,并编辑预设使用的插件。" + } +} diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index d65a284667..93a5319006 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -67,10 +68,10 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 184b7fd791..6fa0e3e4ad 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -5,8 +5,8 @@ * the roster (selection, the new-task default, a read-only view of each * declared composition, and the way into Creator mode). * - * A running session keeps the composition it began with (the host refuses to - * adopt an existing session under a different preset). That is what splits + * A running session keeps the composition it began with (the host switches a + * session's preset only before its first turn). That is what splits * the choice from the display: the hero chip is before-the-fact, while the * header only reports what a session already runs. The default preset is * edited where the roster is visible — the settings section's "make default" @@ -74,8 +74,7 @@ export function apply(ctx: ClientContext): void { const presetSettings = ctx.configForms.get(AGENT_PRESET_SETTINGS_NS) let active = true // Host preferences start at false before their first accepted document. - const codingToolsDisabled = (): boolean => active && toolsSettings.getSnapshot().mode === 'host' - && toolsSettings.getSnapshot().value?.enabled === false + const codingToolsDisabled = (): boolean => active && toolsSettings.getSnapshot().value?.enabled === false const controller = new AgentPresetSettingsController(ctx) const staged: AgentPresetStage = { id: undefined, introduce: false } const seats = new WeakMapWithValues() @@ -199,10 +198,10 @@ export function apply(ctx: ClientContext): void { }) const startCreatorDraft = () => { + // The Host composes the reused draft or new Session; the stage only drives the chip's display and introduction. const seat = mainBlankSeat() ?? unboundSeat seat.stage('cordis', true) - scope.uiWorkspace.startSession(undefined, { clearPreviousDraft: false }) - void seat.apply() + scope.uiWorkspace.startSession(undefined, { clearPreviousDraft: false, agentPreset: 'cordis' }) } scope.effect(() => { diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index dfa2c69945..0a1860ba86 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -3,8 +3,10 @@ * * The new-session screen has no session, so a pick is staged rather than * applied. It reaches a session when one becomes current and is still blank — - * whether the workspace connect created it or reused an existing blank one, - * which is why staging cannot simply ride along on `sessions.create`. + * whether the workspace connect created it or reused an existing blank one. A + * flow that knows its preset before it starts (the settings section's creator + * entry) passes it to session creation instead, and its stage only drives the + * chip's display. * * The stage is forgotten once applied. The next new session starts from the * Host-effective default again. diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 3ab2bdd2aa..317543daf8 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -86,7 +86,7 @@ export class AgentPresetSectionController { while (this.pendingSave !== undefined) await this.pendingSave if (!shouldReset()) return const settings = this.ctx.configForms.get(AGENT_PRESET_SETTINGS_NS).getSnapshot() - if (settings.mode !== 'host' || settings.status !== 'ready' || !settings.writable || settings.revision === undefined) return + if (settings.status !== 'ready' || !settings.writable || settings.revision === undefined) return await this.load() if (!shouldReset() || this.store.getSnapshot().status !== 'ready') return const rows = this.store.getSnapshot().rows diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index f23bde9a6e..c891d1d6d4 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -72,7 +72,6 @@ async function bench(options: { describeGate?: Promise presets?: AgentPresetRow[] developerToolsEnabled?: boolean - memory?: boolean } = {}) { const ctx = new Context() // The host's answer, mutable so a spec can move the default the way the @@ -138,7 +137,6 @@ async function bench(options: { } const remote = new TestRemote(ctx, { settings }) - if (options.memory === true) remote.$host = { home: undefined, isLoopback: false } // The roster and the switch are the AgentPresets Remote namespace; the // shared double carries no generated namespaces, so this spec stages its // own. Registered twice on purpose: the nested key satisfies the plugin's @@ -174,9 +172,9 @@ async function bench(options: { await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() if (options.describeGate === undefined) { await vi.waitFor(() => { - expect(ctx.configForms.developerTools.enabled.getSnapshot()).toBe(options.memory === true ? true : developerToolsEnabled) + expect(ctx.configForms.developerTools.enabled.getSnapshot()).toBe(developerToolsEnabled) }) - if (options.memory !== true) await vi.waitFor(() => { expect(ctx.configForms.get('ui-settings').getSnapshot().status).toBe('ready') }) + await vi.waitFor(() => { expect(ctx.configForms.get('ui-settings').getSnapshot().status).toBe('ready') }) } /** Publish a new Developer tools choice the way the settings mirror does. */ const setDeveloperTools = async (enabled: boolean): Promise => { @@ -214,7 +212,7 @@ function declareConversation(slots: SlotRegistry): () => void { /** A Workspace UI double recording new-session starts. */ function uiWorkspaceDouble() { - const starts: unknown[] = [] + const starts: { workspaceId: unknown; options: unknown }[] = [] return { starts, startSession: (workspaceId?: unknown, options?: unknown) => { starts.push({ workspaceId, options }) }, @@ -933,7 +931,6 @@ describe('ui-agent-preset apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([]) await b.setDeveloperTools(false) - expect(b.ctx.configForms.get('agent-preset-registry').getSnapshot()).toMatchObject({ status: 'ready', mode: 'host', writable: true, revision: 0 }) await vi.waitFor(() => { expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) }) await b.ctx.fiber.dispose() }) @@ -1030,36 +1027,6 @@ describe('ui-agent-preset apply', () => { await b.ctx.fiber.dispose() }) - it.each(['ptc', 'minimal'])('clears a browser-local staged %s choice without rewriting the Host default', async (preset) => { - const b = await bench({ memory: true, presets: [ - ...ROSTER_MOVED.value.presets, { id: 'ptc', isDefault: false }, - ] }) - declareRoot(b.slots) - declareConversation(b.slots) - b.ctx.provide('conversation', {} as never) - const state: { current?: string; byId: Record } = { - byId: { s1: { id: 's1', blank: true } }, - } - const sessions = sessionsDouble(b.ctx, state) - b.ctx.provide('sessions', sessions as never) - b.ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const injectSeat = b.slots.entries('conversation.hero.agentPreset')[0]! - .inject as (sessionId?: SessionId) => AgentPresetSeatInjected & Record - const chip = injectSeat() - await chip.load() - await chip.select(preset) - await b.ctx.configForms.developerTools.setEnabled(false) - const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record)() - await section.load() - expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('minimal') - expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([]) - state.current = 's1' - await injectSeat(SessionId('s1')).load() - sessions.notify() - expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([]) - await b.ctx.fiber.dispose() - }) it('loads the header label from the shared roster store', async () => { const { ctx, slots } = await bench() @@ -1099,7 +1066,7 @@ describe('ui-agent-preset apply', () => { // new-session flow began. expect(section.startCreatorDraft).toBeDefined() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') - expect(uiWorkspace.starts).toHaveLength(1) + expect(uiWorkspace.starts).toEqual([{ workspaceId: undefined, options: { clearPreviousDraft: false, agentPreset: 'cordis' } }]) // A cross-screen stage carries the introduce cue; the chip acknowledges // it once, and a repeat acknowledgement leaves the snapshot untouched. @@ -1112,15 +1079,16 @@ describe('ui-agent-preset apply', () => { conversation() }) - it('applies the creator preset to an existing blank main Session', async () => { + it('lets the new-session flow compose the creator preset on a blank main Session', async () => { const { ctx, slots, calls } = await bench() declareRoot(slots) const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) - const sessions = sessionsDouble(ctx, { + const state = { current: 's1', byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } }, - }) + } + const sessions = sessionsDouble(ctx, state) ctx.provide('sessions', sessions as never) const uiWorkspace = uiWorkspaceDouble() ctx.provide('uiWorkspace', uiWorkspace as never) @@ -1133,10 +1101,13 @@ describe('ui-agent-preset apply', () => { await seat.load() await section.load() section.startCreatorDraft?.() + expect(uiWorkspace.starts).toEqual([{ workspaceId: undefined, options: { clearPreviousDraft: false, agentPreset: 'cordis' } }]) - await vi.waitFor(() => { expect(calls).toContain('select:cordis') }) - expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') - expect(uiWorkspace.starts).toEqual([{ workspaceId: undefined, options: { clearPreviousDraft: false } }]) + // The Host composes the reused Session; the chip only follows its projection. + state.byId.s1 = { id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' } } + sessions.notify() + await vi.waitFor(() => { expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') }) + expect(calls).not.toContain('select:cordis') conversation() }) diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index ba2d1841b9..f1a88d720a 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -7,7 +7,7 @@ import { en } from '../src/client/locales.ts' function fixture() { const form = createSnapshotStore>({ - status: 'ready', mode: 'host', writable: true, revision: 7, value: { selectedDefault: 'minimal' }, base: {}, user: {}, + status: 'ready', writable: true, revision: 7, value: { selectedDefault: 'minimal' }, base: {}, user: {}, }) const remote = { agentPresets: { list: vi.fn(async (): Promise<{ ok: true; value: { presets: AgentPresetRow[] } }> => ({ @@ -149,7 +149,7 @@ describe('Coding Tools default reconciliation', () => { it.each([ { status: 'loading' as const }, { status: 'unavailable' as const }, - { mode: 'memory' as const }, { writable: false }, { revision: undefined }, + { writable: false }, { revision: undefined }, ])('does not write without a writable accepted Host form: %j', async (patch) => { const f = setup() f.form.set({ ...f.form.getSnapshot(), ...patch }) diff --git a/packages/client/ui-agent-preset/tsdown.config.ts b/packages/client/ui-agent-preset/tsdown.config.ts index 3f5cc0a823..7aa03cebc1 100644 --- a/packages/client/ui-agent-preset/tsdown.config.ts +++ b/packages/client/ui-agent-preset/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-approval/locale/en.json b/packages/client/ui-approval/locale/en.json new file mode 100644 index 0000000000..48c1a5785f --- /dev/null +++ b/packages/client/ui-approval/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-approval" + } +} diff --git a/packages/client/ui-approval/locale/zh.json b/packages/client/ui-approval/locale/zh.json new file mode 100644 index 0000000000..e93a40b026 --- /dev/null +++ b/packages/client/ui-approval/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "操作审批界面", + "description": "展示待审批的操作,并将用户选择返回给运行中的任务。" + } +} diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 4d8ef679e1..8c86cc1348 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -59,11 +60,11 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-approval/tsdown.config.ts b/packages/client/ui-approval/tsdown.config.ts index 4ddd1fdffe..7aa03cebc1 100644 --- a/packages/client/ui-approval/tsdown.config.ts +++ b/packages/client/ui-approval/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-approval', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index fa658714af..123b686b4e 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -27,8 +27,8 @@ en: 836a62c8f73366ac zh: 35da7836eaf68a67 /deepseek-ai-dsh-client-ui-attachment/understand-the-implementation: - en: 415bd2b09bb84b6b - zh: 2e49a39fb3957b4f + en: 07204b7d538b7434 + zh: 705cc6b0c9387cce /deepseek-ai-dsh-client-ui-attachment/further-exploration: en: 733ac3e4df52d6e9 zh: 98a6a8cac7d66645 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 4c344a07d7..4fee29e123 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -49,7 +49,7 @@ While a file drag is over the page, the full-viewport overlay announces the drop
Implementation internals — click to expand -The plugin waits for `conversation.input.attachments`, `conversation.message.images`, `conversation.trajectory.images`, and `tool.call.images` through `ctx.slots.inject`. It then registers the composer rail, document drop target, shared history gallery for Chat, Trajectory, and Tool results, and original-image lightbox. The presentation components are driven entirely by props: the slot owner supplies attachment data, image loading, callbacks, and the locale translator; the package entry exports no components. +The plugin provides the root-scoped `conversation.images.content` Factory. Its official `MessageImages` entries fill `conversation.chat.user.images`, `conversation.chat.message.images`, `conversation.trajectory.images`, and `tool.call.images` at priority 100 through `ctx.slots.inject`. A lower-priority entry replaces only its own position; removing it restores the official entry. All defaults reuse one gallery with explicit image references, authorized loader, layout hints, and optional complete labels. The Factory owns gallery presentation and default copy, not Session state or read authorization. Each domain owns its Slot declaration. The composer attachment rail remains a separate entry, and the package entry exports no components. | File | Role | |---|---| diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index c9cc5e60f3..706a6d97c7 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -49,7 +49,7 @@ Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪
实现细节——点击展开 -插件通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images`、`conversation.trajectory.images` 与 `tool.call.images`。随后它注册 composer rail、文档拖放目标、供 Chat、Trajectory 与工具结果共用的历史图片 gallery,以及原图灯箱。呈现组件仅依赖 props:slot 持有方提供附件数据、图片加载、回调与语言包翻译器;包入口不导出任何组件。 +插件提供 root 作用域的 `conversation.images.content` Factory。官方 `MessageImages` 条目通过 `ctx.slots.inject`,以优先级 100 填入 `conversation.chat.user.images`、`conversation.chat.message.images`、`conversation.trajectory.images` 和 `tool.call.images`。较低优先级的条目只替换自身所在位置,移除后恢复官方条目。各默认条目复用同一画廊,显式传入图片引用、授权 loader、布局参数和可选的完整 labels。Factory 负责画廊展示与默认文案,不拥有 Session 状态或读取授权。各领域拥有自己的 Slot 声明。Composer 附件栏仍为独立条目,包入口不导出任何组件。 | 文件 | 职责 | |---|---| diff --git a/packages/client/ui-attachment/locale/en.json b/packages/client/ui-attachment/locale/en.json new file mode 100644 index 0000000000..5d1be6fe24 --- /dev/null +++ b/packages/client/ui-attachment/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-attachment" + } +} diff --git a/packages/client/ui-attachment/locale/zh.json b/packages/client/ui-attachment/locale/zh.json new file mode 100644 index 0000000000..13fe1edf53 --- /dev/null +++ b/packages/client/ui-attachment/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "附件展示", + "description": "在消息输入、对话和运行记录中展示附件与图片。" + } +} diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index e37afaf50d..3b2483a7e6 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,11 +23,13 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-renderer", @@ -44,8 +46,7 @@ "license": "MIT", "devDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", + "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:*", @@ -59,6 +60,7 @@ "clsx": "^2.0.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 7f350bbc94..a067ffd446 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -2,9 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ImageLightboxLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' import css from './MessageImage.module.css' +export type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' + /** Loads a session-authorized durable image URL and may expose a cached URL synchronously. */ export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise) & { peek?: (attachment: ImageAttachmentRef) => string | undefined @@ -26,22 +28,6 @@ export type MessageImageSpec = } } -/** Message-image strings the owner resolves from its own locale namespace. */ -export interface MessageImageLabels { - /** Fallback display name for an unnamed image. */ - image: string - /** Thumbnail tooltip inviting the original-image preview. */ - open: string - /** Accessible thumbnail label; receives the image's display name. */ - openNamed: (label: string) => string - /** Loading placeholder shown until bytes resolve. */ - loading: string - /** Retry-control label shown when the load fails. */ - loadFailed: string - /** Lightbox strings forwarded to the opened preview. */ - lightbox: ImageLightboxLabels -} - /** Display box for a lone image (DeepSeek Chat rule): long edge 240px with * the rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by * `object-fit: cover` — and never upscaled past the image's natural size. The diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 192147f877..76a0718c1f 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -1,17 +1,33 @@ -import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' +/** Official image gallery Factory and default image Slot adapter. */ +import { useMemo } from 'react' +import type { FactoryComponentPropsOf, PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots' +import type { MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { ImageGallery } from '../MessageImage.tsx' import { messageImageLabels } from './labels.ts' -/** Historical message-image slot entry. */ -export function MessageImages({ images, loadImage, align, compact = false, thumbnail = false, t }: MessageImagesProps) { - return ( - - ) +type MessageImagesProps = MessageImagesOwnerProps & PropsRenderFactories + +/** + * Render a gallery with an authorized loader and caller-owned or default labels. + * @param props - image sources, authorized loader, layout hints, optional labels, and Factory locale seat. + * @returns the official gallery; no Session binding is needed by this component. + */ +export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t }: +FactoryComponentPropsOf<'conversation.images.content'>) { + const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t]) + return +} + +/** + * Fill a message, trajectory, or tool image position through the shared Content Factory. + * @param props - Slot owner image data, authorized loader, and framework Factory renderer. + * @returns the official gallery occurrence. + */ +export function MessageImages({ images, loadImage, align, compact, thumbnail, renderFactorySlot }: MessageImagesProps) { + return renderFactorySlot('conversation.images.content', { + images, loadImage, align, + ...compact === undefined ? {} : { compact }, + ...thumbnail === undefined ? {} : { thumbnail }, + }) } diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 62deeebfc3..75059f84b3 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -1,4 +1,4 @@ -/** Browser attachment plugin: fills conversation's composer and image slots. */ +/** Browser attachment plugin: composer entries and independently replaceable image galleries. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -6,29 +6,33 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-tool/client' import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client' import { ComposerAttachments } from './ComposerAttachments.tsx' -import { MessageImages } from './MessageImages.tsx' +import { MessageImages, MessageImagesContent } from './MessageImages.tsx' -/** Slot registry required by this presentation plugin. */ +/** Slot registry required by the attachment presentation plugin. */ export const inject = ['slots'] -/** Register attachment presentation without exporting React components as package values. */ +/** + * Register composer attachments and the shared gallery with its domain adapters. + * @param ctx - client plugin context with the Slot registry. + */ export function apply(ctx: ClientContext): void { + ctx.slots.registerFactory({ + name: 'conversation.images.content', scope: 'root', locale: 'conversation', + }, MessageImagesContent) ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({ name: 'conversation.input.attachments', locale: 'conversation', }, ComposerAttachments)) - ctx.slots.inject('conversation.message.images', () => ctx.slots.register({ - name: 'conversation.message.images', - locale: 'conversation', + ctx.slots.inject('conversation.chat.message.images', () => ctx.slots.register({ + name: 'conversation.chat.message.images', priority: 100, + }, MessageImages)) + ctx.slots.inject('conversation.chat.user.images', () => ctx.slots.register({ + name: 'conversation.chat.user.images', priority: 100, }, MessageImages)) ctx.slots.inject('conversation.trajectory.images', () => ctx.slots.register({ - name: 'conversation.trajectory.images', - locale: 'conversation', + name: 'conversation.trajectory.images', priority: 100, }, MessageImages)) - // The tool image gallery reuses the message gallery renderer: its owner - // carries the same images/loadImage/align share the message arm does. ctx.slots.inject('tool.call.images', () => ctx.slots.register({ - name: 'tool.call.images', - locale: 'conversation', + name: 'tool.call.images', priority: 100, }, MessageImages)) } diff --git a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx new file mode 100644 index 0000000000..04f83f3ba8 --- /dev/null +++ b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +/** Independent domain image positions with one shared official gallery Factory. */ +import { act, cleanup, within } from '@testing-library/react' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/assembly.ts' +import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import type { MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import * as attachmentPlugin from '../src/client/index.ts' + +afterEach(cleanup) + +const SLOTS = [ + 'conversation.chat.user.images', 'conversation.chat.message.images', + 'conversation.trajectory.images', 'tool.call.images', +] as const + +async function bench() { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const locale = new LocaleRuntime(runtime.ctx) + locale.register('conversation', { en, zh }) + locale.setLocale('en') + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + new UiConversation(runtime.ctx, runtime.sessions) + await runtime.mount(attachmentPlugin) + await runtime.sessions.add({ id: 'image-domains' }) + const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-domains')) + const input: MessageImagesOwnerProps = { + images: [{ attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png', + name: 'shared.png', width: 1, height: 1, bytes: 68, + } }], + loadImage: vi.fn(async () => 'blob:shared'), align: 'start', + } + await runtime.root.declare({ + 'conversation.chat.user.images': { kind: 'single', scope: 'session' }, + 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, + 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, + 'tool.call.images': { kind: 'single', scope: 'session' }, + }, ({ renderSlot, SessionProvider }) => + {SLOTS.map(name =>
{renderSlot(name, input)}
)} +
) + return { runtime, view: runtime.renderRoot() } +} + +describe('domain image Slots', () => { + it.each(SLOTS)('replaces only %s and restores the shared gallery on disposal', async (name) => { + const { runtime, view } = await bench() + expect(runtime.factoryOf('conversation.images.content').scope).toBe('root') + const images = new Map() + for (const slot of SLOTS) { + expect(runtime.slots.entries(slot)[0]?.options.priority).toBe(100) + images.set(slot, await within(view.getByTestId(slot)).findByAltText('shared.png')) + } + const override = await runtime.mount({ inject: ['slots'], apply(ctx) { + ctx.slots.register({ name, priority: 0 }, () => Domain override) + } }) + const target = within(view.getByTestId(name)) + expect(target.getByText('Domain override')).toBeTruthy() + expect(target.queryByAltText('shared.png')).toBeNull() + for (const other of SLOTS.filter(slot => slot !== name)) { + expect(within(view.getByTestId(other)).getByAltText('shared.png')).toBe(images.get(other)) + } + await act(async () => { await override.dispose() }) + expect(target.queryByText('Domain override')).toBeNull() + expect(await target.findByAltText('shared.png')).toBeTruthy() + for (const other of SLOTS.filter(slot => slot !== name)) { + expect(within(view.getByTestId(other)).getByAltText('shared.png')).toBe(images.get(other)) + } + }) +}) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 7fa2c0a980..d447df5fd9 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -1,18 +1,18 @@ // @vitest-environment jsdom -import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { useState } from 'react' +import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/assembly.ts' import { ImageGallery, MessageImage } from '../src/MessageImage.tsx' import type { MessageImageLabels } from '../src/MessageImage.tsx' -import { MessageImages } from '../src/client/MessageImages.tsx' - -// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] -const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) +import * as attachmentPlugin from '../src/client/index.ts' afterEach(cleanup) @@ -34,23 +34,6 @@ const attachment = { name: 'history.png', } -type AttentionSnapshot = Parameters[0]>[0] -type TrajectorySnapshot = Parameters[0]>[0] - -const noAttention: AttentionSnapshot = new Map() -const emptyTrajectory: TrajectorySnapshot = { - eventNodes: [], - eventLocations: new Map(), - requests: [], - callSchemas: new Map(), - partial: null, - runningCalls: [], -} -const useSessionStatus: MessageImagesProps['useSessionStatus'] = selector => selector(noAttention) -const useConversation: MessageImagesProps['useConversation'] = selector => selector(EMPTY_CONVERSATION_SNAPSHOT) -const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_SNAPSHOT) -const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory) - describe('MessageImage', () => { it('renders a cached URL on the first frame while refreshing it', () => { const load = Object.assign(vi.fn(() => new Promise(() => {})), { @@ -285,65 +268,77 @@ describe('ImageGallery', () => { expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3) }) + it('uses complete Factory labels unchanged and restores defaults without remounting the image', async () => { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const locale = new LocaleRuntime(runtime.ctx) + locale.register('conversation', { en, zh }) + locale.setLocale('en') + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.mount(attachmentPlugin) + const images = [{ preview: { url: 'blob:labels', name: 'preview.png' } }] + const loadImage = vi.fn(async () => 'blob:unused') + await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => { + const [custom, setCustom] = useState(true) + return <> + + {renderFactorySlot('conversation.images.content', { + images, loadImage, align: 'start', ...(custom ? { labels } : {}), + })} + + }) + const view = runtime.renderRoot() + const image = view.getByAltText('preview.png') + const frame = view.getByRole('button', { name: 'preview.png,点击查看原图' }) + expect(frame.getAttribute('title')).toBe(labels.open) + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: labels.lightbox.dialog })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: labels.lightbox.close })) + fireEvent.click(view.getByRole('button', { name: 'Default labels' })) + const defaultFrame = view.getByRole('button', { name: 'preview.png, click to view original' }) + expect(defaultFrame).toBe(frame) + expect(view.getByAltText('preview.png')).toBe(image) + expect(defaultFrame.getAttribute('title')).toBe('View original') + fireEvent.click(defaultFrame) + expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'Close original image preview' })) + expect(loadImage).not.toHaveBeenCalled() + }) + it('renders the conversation slot entry with translated labels', async () => { - const t = ((key: string, params?: Readonly>) => { - const translated: Record = { - 'image.label': '图片', - 'image.openOriginal': '查看原图', - 'image.loading': '图片加载中…', - 'image.loadFailed': '图片加载失败,点击重试', - 'image.preview': '原图预览', - 'image.closePreview': '关闭原图预览', - } - if (key === 'image.openOriginalLabel') { - const label = params?.label - return `${typeof label === 'string' ? label : ''},点击查看原图` - } - return translated[key] ?? key - }) as MessageImagesProps['t'] + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const locale = new LocaleRuntime(runtime.ctx) + locale.register('conversation', { en, zh }) + locale.setLocale('zh') + runtime.ctx.provide('locale', locale) + new UiConversation(runtime.ctx, runtime.sessions) + runtime.slots.installLocale(locale) + await runtime.declare({ 'conversation.chat.message.images': { kind: 'single', scope: 'session' } }) + await runtime.mount(attachmentPlugin) + await runtime.sessions.add({ id: 'message-images-test' }) + const session = runtime.sessions.retainFor(runtime.ctx, SessionId('message-images-test')) const loadImage = vi.fn().mockResolvedValue('blob:slot-image') - const useSession: MessageImagesProps['useSession'] = () => { - throw new Error('MessageImages does not read the session snapshot') - } - const useInput: MessageImagesProps['useInput'] = () => { - throw new Error('MessageImages does not read the input snapshot') - } - const useSessions: MessageImagesProps['useSessions'] = () => { - throw new Error('MessageImages does not read the session list snapshot') - } - const useWorkspaces: MessageImagesProps['useWorkspaces'] = () => { - throw new Error('MessageImages does not read the workspace list snapshot') - } - const props: MessageImagesProps = { - sessionId: 'message-images-test' as MessageImagesProps['sessionId'], - useSession, - useSessions, - usePanelInfo, useSessionRetainInfo: () => undefined, useResource, - useSessionStatus, - useWorkspaces, - useProjection: () => undefined, - useConversation, - useChat, - useTrajectory, - useInput, - inputActions: { - captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), - insertText: () => false, - setDraft: vi.fn(), - persistDraft: vi.fn(), - addAttachments: vi.fn(() => true), - removeAttachment: vi.fn(), - pruneAttachments: vi.fn(), - submit: vi.fn(), - }, - images: [{ attachment }], - loadImage, - align: 'end', - t, - } - const view = render() + const slot = runtime.renderSlot('conversation.chat.message.images', { + images: [{ attachment }], loadImage, align: 'end', + }, { session }) + const { view, container } = slot await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) expect(view.getByRole('button', { name: 'history.png,点击查看原图' })).toBeTruthy() - expect(view.container.querySelector('[data-align="end"]')).not.toBeNull() + expect(container.querySelector('[data-align="end"]')).not.toBeNull() + expect(loadImage).toHaveBeenCalledWith(attachment) + expect(view.getByRole('button', { name: 'history.png,点击查看原图' }).getAttribute('data-variant')).toBe('single') + for (const [compact, thumbnail, variant] of [ + [true, false, 'tile'], + [false, true, 'thumbnail'], + [false, false, 'single'], + ] as const) { + slot.update({ images: [{ attachment }], loadImage, align: 'end', compact, thumbnail }) + expect(view.getByRole('button', { name: 'history.png,点击查看原图' }).getAttribute('data-variant')).toBe(variant) + expect(view.getByAltText('history.png').getAttribute('src')).toBe('blob:slot-image') + expect(container.querySelector('[data-align="end"]')).not.toBeNull() + } + expect(loadImage).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index a83b33e8e0..6181d8db35 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -1,26 +1,42 @@ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { TestSessions } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyHost } from '../src/index.ts' import { apply, inject } from '../src/client/index.ts' import { ComposerAttachments } from '../src/client/ComposerAttachments.tsx' -import { MessageImages } from '../src/client/MessageImages.tsx' +import { MessageImages, MessageImagesContent } from '../src/client/MessageImages.tsx' + +const IMAGE_SLOTS = [ + 'conversation.chat.message.images', 'conversation.chat.user.images', + 'conversation.trajectory.images', 'tool.call.images', +] as const async function bench() { const ctx = new Context() + const sessions = new TestSessions(async (action) => { await action() }, ctx) + onTestFinished(async () => { + await sessions.disposeScopes() + await ctx.fiber.dispose() + }) + ctx.provide('sessions', sessions) + ctx.provide('locale', new LocaleRuntime(ctx)) await ctx.plugin(SlotRegistry).await() ctx.slots.register({ name: 'root', children: { 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, - 'conversation.message.images': { kind: 'single', scope: 'session' }, + 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, + 'conversation.chat.user.images': { kind: 'single', scope: 'session' }, 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, 'tool.call.images': { kind: 'single', scope: 'session' }, }, } as never, () => null) + const registerFactory = vi.spyOn(ctx.slots, 'registerFactory') const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, fiber } + return { ctx, fiber, registerFactory } } describe('attachment plugin', () => { @@ -29,30 +45,21 @@ describe('attachment plugin', () => { }) it('registers all entries and removes them with the plugin fiber', async () => { - const { ctx, fiber } = await bench() + const { ctx, fiber, registerFactory } = await bench() expect(inject).toEqual(['slots']) + expect(registerFactory).toHaveBeenCalledWith({ + name: 'conversation.images.content', scope: 'root', locale: 'conversation', + }, MessageImagesContent) + expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(1) expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{ - locale: 'conversation', - component: ComposerAttachments, + locale: 'conversation', component: ComposerAttachments, }]) - expect(ctx.slots.entries('conversation.message.images')).toMatchObject([{ - locale: 'conversation', - component: MessageImages, - }]) - expect(ctx.slots.entries('conversation.trajectory.images')).toMatchObject([{ - locale: 'conversation', - component: MessageImages, - }]) - expect(ctx.slots.entries('tool.call.images')).toMatchObject([{ - locale: 'conversation', - component: MessageImages, - }]) - + for (const name of IMAGE_SLOTS) { + expect(ctx.slots.entries(name)).toMatchObject([{ component: MessageImages, options: { priority: 100 } }]) + } await fiber.dispose() - expect(ctx.slots.entries('conversation.input.attachments')).toHaveLength(0) - expect(ctx.slots.entries('conversation.message.images')).toHaveLength(0) - expect(ctx.slots.entries('conversation.trajectory.images')).toHaveLength(0) - expect(ctx.slots.entries('tool.call.images')).toHaveLength(0) + for (const name of IMAGE_SLOTS) expect(ctx.slots.entries(name)).toHaveLength(0) + expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(0) }) }) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 1f5b771ac1..136e1ebb26 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../ui-primitives" + }, + { + "path": "../locale/tsconfig.client.json" } ] } diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts index 2c903ce9af..3a0694bdf9 100644 --- a/packages/client/ui-attachment/tsdown.config.ts +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -1,6 +1,6 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-ui-attachment', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/ui-brand-official/locale/en.json b/packages/client/ui-brand-official/locale/en.json new file mode 100644 index 0000000000..09781290d4 --- /dev/null +++ b/packages/client/ui-brand-official/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-brand-official" + } +} diff --git a/packages/client/ui-brand-official/locale/zh.json b/packages/client/ui-brand-official/locale/zh.json new file mode 100644 index 0000000000..479797b84e --- /dev/null +++ b/packages/client/ui-brand-official/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek 品牌展示", + "description": "在应用侧栏等位置展示官方品牌元素。" + } +} diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index b4587a84ac..e21a4838eb 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -48,11 +49,11 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "react": "^18.2.0", "react-dom": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-brand-official/tsdown.config.ts b/packages/client/ui-brand-official/tsdown.config.ts index 9edc90cbc9..7aa03cebc1 100644 --- a/packages/client/ui-brand-official/tsdown.config.ts +++ b/packages/client/ui-brand-official/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-brand-official', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 91c230be23..62f04ab2b8 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -15,8 +15,8 @@ en: 41cd21266011d571 zh: 3172d1a2acbb27b7 /deepseek-ai-dsh-client-ui-chat/reference-previews: - en: 0a1948349b1fbfd9 - zh: 56256e4d695e5192 + en: aa6b1abec265bf1b + zh: 7260ce78afba2584 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: en: cbf1f0a2205046a5 zh: 2e59d0815351349f @@ -24,20 +24,20 @@ en: 2157fcd6b0d100b5 zh: 9e865ac3953ebe30 /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: 14343dd14e493f4b - zh: 7cc7a25d05ea798f + en: bb20da372b144302 + zh: 75a8c5ab7ec8d665 /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: b4cd36e7ab91281e zh: 8ccf412b9dde61d5 /deepseek-ai-dsh-client-ui-chat/turn-process-folding: - en: 0ca3e8c4eab1ca78 - zh: 1a1c71bab151eb7d + en: 4cd7c81d70d2f365 + zh: 18eca6d16e44b6c2 /deepseek-ai-dsh-client-ui-chat/grouped-rendering: - en: c769b9eaddf02549 - zh: e36efc654be557e2 + en: bc8d1d786c9affea + zh: 3139f0228527a174 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 69b021c5a5d8b5a7 - zh: 091ae7986c7a8a20 + en: 3f4e6c161b4583bb + zh: e4265bb2bce8eb1c /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 02bcbe3ddb..697efe25cf 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -33,11 +33,19 @@ File-mention providers receive the viewed Session ID with the closing-turn owner ## Reference previews -Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. +Sent generic-file attachments retain the draft's [file-card radius](../../../docs/ui-radius.md#radius-scale). + +`ChatView` supplies file and HTTP(S) navigation through the existing `MarkdownDelegateProvider` around its node list, passing `requestOpenFile`, `openExternalLink`, and `fileImages` directly. Its file opener retains Chat's failure feedback and retry handling. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. + +Native message images use separate user and output positions. The `conversation.chat.user.content` Factory declares `conversation.chat.user.images`, which `UserContent` renders; the Assistant node registration declares `conversation.chat.message.images`, which `AssistantMarkdown` renders. Both receive image sources, the authorized `loadImage`, and layout options through explicit props. Their official `MessageImages` entries call the shared root-scoped `conversation.images.content` Factory. Each position can be replaced independently without duplicating the gallery implementation. + +The session-scoped `conversation.chat.user.content` Factory renders only the user content stack: text, extra blocks, image/file attachments in input order, and reference summaries. Its [`UserContentInput`](src/client/contract/slots.ts) carries prepared `content`, transform `facts`, display props, and explicit `loadImage`; node-owned injections provide reference actions. It calls the shared `conversation.user.text` Factory for text and its own image Slot for images. Direct callers need a Session binding and supply their own row wrapper. + +`UserStyleBubble` and the [source-specific wrappers]() retain the user row, keys, pending/echo markers, timestamps, copy controls, and other message actions. The content Factory reads neither source-specific models nor private Context, and is not a global user-content override entry point. Standalone Markdown images show contained previews and open the shared image lightbox; local paths resolve against the viewed workspace after settlement. Image file links keep their sidebar activation and show a thumbnail after hover dwell or keyboard focus. Escape dismisses the thumbnail. Failed images retain a localized status and their description; no duplicate-image filtering is applied. -Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: In-App Sidebar (default) opens a new right-Sidebar Browser tab, while Default Browser opens an external tab. The setting follows Keyboard shortcuts and is shown only while the Sidebar Browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. +Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: In-App Sidebar (default) opens a new right-Sidebar Browser tab, while Default Browser opens an external tab. The setting follows Keyboard shortcuts and is shown only while the Sidebar Browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on the Host for authenticated browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. ## Hidden Chat rows @@ -60,9 +68,9 @@ A completed Turn shows an expandable usage row only when the loaded window inclu Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact. -The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). Another plugin replaces one pill by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a pill. Each pill carries its id in `data-composer-stat`. An open pill dialog closes on Escape or on a pointerdown or click outside it, so activating another pill by pointer or keyboard swaps dialogs instead of stacking them. +The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. -On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers. +Explicit selections update every consumer immediately; accepted Host settings reconcile the live value, so a refused or pending write keeps the selected mode until the Host answers. Preference menus restore focus to their trigger without scrolling before publishing a new selection. @@ -84,6 +92,8 @@ When Chat ends with an open Turn control and that Turn has no visible input, the Work-details modes control process-group display and reasoning previews. Compact, Standard, and Detailed fold eligible completed Turns without hiding the final answer; Verbose retains the duration/status header without a collapse action and shows historical process rows directly. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets. +The Settings row below Work details offers an [independent collapse-timing preference](src/client/conversation-nodes/README.md#collapse-timing), saved through the same Host profile configuration. + ----- @@ -93,9 +103,11 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea `groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. +The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the latest loaded exchange, not just the final row. Nodes declare their group-tree position through `groupTree`; a Turn holding an `exchange` Node opens an exchange, and the exchange lasts until the next opening Turn. Moving the exchange start notifies only Nodes in Turns that entered or left it. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder. + Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Native and nested results retain event presentation metadata, so historical file and command labels use the operation’s location. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration. -The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. CSS variables do not belong in the Group Definition. +The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. The `conversation.chat.flow` slot adds a `display: contents` DOM anchor; row selectors match that anchor directly, without repeating its column ancestry. The running status shares the rows' parent, so its divider uses the same visible-sibling rules. The anchor contributes no spacing of its own. CSS variables do not belong in the Group Definition. Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Tool and process rows sit 6px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px around them. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it. @@ -105,7 +117,7 @@ Scroll-edge fades initialize when `ResizeObserver` reports the open group's layo Each group owns local `useDisclosure` state that survives mode changes while its component stays mounted. -The `conversation.chat.reasoning-body` chain offers the original reasoning text and streaming state only when its body is expanded. An unclaimed body keeps compact Markdown; a claiming display extension must preserve access to the original text. Extensions do not replace the reasoning preview or its disclosure policy. +The session-scoped `conversation.chat.reasoning.body` single Slot offers the original reasoning text and streaming state only when its body is expanded. Its official entry registers at priority 100 and calls the `conversation.chat.reasoning.content` Factory. A replacement uses a lower priority number and may call that same Factory with original or derived text. The root-scoped Factory requires no Session and fixes compact typography. Its optional `labels` accepts complete `MarkdownLabels` unchanged; omission uses Chat's localized defaults. The host supplies no inline fallback or plugin action state. Display extensions must preserve access to the original text and own their controls inside their Body wrapper. Extensions do not replace the reasoning preview or its disclosure policy. The [reasoning display rules](src/client/conversation-nodes/README.md#display-modes) define which blocks produce rows. The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. Source callbacks retain their receiver and stable identity. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state. @@ -116,13 +128,21 @@ Process rows share one text and icon color in every Work details mode: tertiary ## Scroll ownership -Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the exact floor, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer. +Chat waits for initial history before automatic content, scroll, or size updates initialize reading state. Running Sessions restore their initial visibility and position before fold animations become eligible. + +Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the floor within subpixel rounding, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Under completion-time folding, submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer. The turn rail and back-to-bottom button sit outside the clipped transcript. They use the shared conversation scrollport for sticky positioning, or the Chat frame for absolute positioning when Chat owns its scrollport. The rail hides when the transcript's available width, excluding its horizontal padding, is at most 900px; the browser viewport width is not the criterion. The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overflow is clipped without creating a scroll container. Sticky Markdown code banners and expanded compaction headers therefore retain the actual conversation scrollport as their reference when no nearer scrolling ancestor exists. Capped process groups and terminal sections keep their own scrollports. -Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; a reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following. +Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; only that scrollport's own `scrollend` settles its animation. Browsers without `scrollend` may retain the native follow target until a later bottom-aligned update or reader gesture. A reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following. + +Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. + +Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow. + +Fold coordination is viewport-local: reservations and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node dispatch, supplies Factory rendering for local echoes, and shares the existing Chat store and Session node sources. The image entry Factory owns the replaceable image Slot. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView. | Outer follows | Open group follows | Back-to-bottom button | New content | |---|---|---|---| @@ -146,7 +166,7 @@ Within a process group, wheel, touchstart, and any pointerdown interrupt an acti `useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and requests preservation from the viewport. Reading gestures release the paging anchor, but composer clicks, typing, and non-scrolling keys retain it; while a page is still loading, `scrollend` captures the reader's new position. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test. -Active-Turn highlighting is approximate: `readVisibleTurn` binary-searches the content column's direct Node/Group boxes and retains the preceding candidate in gaps. It neither hit-tests the document nor searches Group members or all Turn markers. Empty Seats retain zero-height in-flow boxes so outer positions remain ordered without extra spacing. This lookup does not change semantic position capture or paging compensation. +Active-Turn highlighting is approximate: `readVisibleTurn` binary-searches the flow slot's direct Node/Group boxes (or the column's in standalone rendering) and retains the preceding candidate in gaps. It neither hit-tests the document nor searches Group members or all Turn markers. Empty Seats retain zero-height in-flow boxes so outer positions remain ordered without extra spacing. This lookup does not change semantic position capture or paging compensation.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 560e37e1fa..fd0bec6b81 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -33,11 +33,19 @@ kind: "package-reference" ## 引用预览 -Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 +已发送的普通文件附件保留草稿中的[文件卡片圆角](../../../docs/ui-radius.zh.md#radius-scale)。 + +`ChatView` 通过节点列表外围既有的 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航,直接传入 `requestOpenFile`、`openExternalLink` 和 `fileImages`。文件打开动作保留 Chat 的失败反馈与重试处理。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 + +原生消息图片使用独立的用户与输出位置。`conversation.chat.user.content` Factory 声明 `conversation.chat.user.images`,由 `UserContent` 渲染;Assistant 节点注册声明 `conversation.chat.message.images`,由 `AssistantMarkdown` 渲染。两者通过显式 props 接收图片来源、授权 `loadImage` 和布局参数。官方 `MessageImages` 条目调用共享的 root 作用域 `conversation.images.content` Factory。每个位置均可独立替换,无需复制画廊实现。 + +Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内容区域:文本、额外内容块、按输入顺序排列的图片/文件附件及引用摘要。[`UserContentInput`](src/client/contract/slots.ts) 携带预处理的 `content`、变换 `facts`、显示 props 和显式 `loadImage`;引用动作由节点自己的 Injection 提供。正文调用共享 `conversation.user.text` Factory,图片调用自身拥有的图片 Slot。直接调用需要 Session 绑定,行包装层由调用方提供。 + +`UserStyleBubble` 和[各来源包装层]() 保留用户行、key、pending/echo 标记、时间、复制控件及其他消息操作。内容 Factory 不读取来源专属模型或私有 Context,也不是全局用户内容替换入口。 独立的 Markdown 图片以内嵌预览显示,点击打开共享图片浮层;本地路径在消息落定后基于当前查看的工作区解析。图片文件链接保持点击打开侧栏,鼠标停留或键盘聚焦时显示缩略图,Esc 关闭缩略图。图片加载失败时保留本地化状态与图片说明;不执行重复图片过滤。 -设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项位于快捷键之后,仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 +设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项位于快捷键之后,仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。已认证浏览器的 `ui-chat.linkOpening` 偏好在 Host 上持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 ## Chat 隐藏的行 @@ -60,9 +68,9 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, 设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 -输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个胶囊;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增胶囊。每个胶囊在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个胶囊会切换对话框,而不会叠加两个。 +输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。 -在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。 +明确选择会立即更新所有使用方;Host 接受的设置会校正实时值,因此写入被拒绝或尚未应答时保持所选模式,直到 Host 应答。 偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。 @@ -84,6 +92,8 @@ Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第 工作步骤展示模式控制过程组显示与推理预览。简洁、标准、详细模式收起符合条件的已完成轮次,不隐藏最终答案;完全展开模式保留时长或状态抬头,但不支持收起,历史过程行直接显示。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。 +工作步骤展示下方的设置行提供[独立的收起时机设置](src/client/conversation-nodes/README.zh.md#collapse-timing),通过相同的 Host profile 配置保存。 + ----- @@ -93,9 +103,11 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过 `groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 +Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的最新交互段时为 true,而不只是列表最后一行。节点通过 `groupTree` 声明自己在分组树中的位置;含 `exchange` 节点的轮次开启一个交互段,交互段持续到下一个开启轮次。交互段起点移动时,只有进入或离开该交互段的轮次中的节点收到通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。 + 实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。原生与嵌套结果保留事件中的展示元数据,使历史文件与命令标签使用执行时的位置。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。 -过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。 +过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。`conversation.chat.flow` 插槽添加一个 `display: contents` DOM 锚点;行选择器直接匹配该锚点,不重复匹配其正文容器祖先。运行状态与各行共享父级,使分隔线使用同一套可见兄弟节点规则。锚点自身不产生间距。CSS 变量不属于 Group Definition。 运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。工具和过程行之间相隔 6px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件周围保留 16px。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。 @@ -105,7 +117,7 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过 每个组拥有本地 `useDisclosure` 状态,组件保持挂载时,模式切换保留该状态。 -`conversation.chat.reasoning-body` 链仅在思考正文展开时提供原始思考文本和流式状态。未被认领的正文使用紧凑 Markdown;认领显示的扩展必须保留查看原文的能力。扩展不替换思考预览或展开策略。 +Session 作用域的 `conversation.chat.reasoning.body` single Slot 仅在思考正文展开时提供原始思考文本和流式状态。官方条目以优先级 100 注册,并调用 `conversation.chat.reasoning.content` Factory。替换项使用更小的优先级数值,可向同一个 Factory 传入原文或派生显示文本。root 作用域的 Factory 不要求 Session,并固定使用紧凑排版。其可选 `labels` 原样接受完整的 `MarkdownLabels`,省略时使用 Chat 的默认本地化文案。宿主不提供内联 fallback 或插件操作状态。显示扩展必须保留查看原文的能力,并在自己的 Body 包装内维护控件。扩展不替换思考预览或展开策略。哪些块生成思考行由[思考展示规则](src/client/conversation-nodes/README.zh.md#display-modes)定义。 Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。来源回调保留接收对象及稳定引用。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。 @@ -116,13 +128,21 @@ Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` ## 滚动归属 -Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及确实到达最底部的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。提交正文输入或 steering 时立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。 +Chat 等待初始历史就绪后,才允许自动内容、滚动或尺寸更新初始化阅读状态。进行中的 Session 先恢复初始显隐和位置,再允许折叠动画。 + +Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及到达底部(容许小于 1px 的取整误差)的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。采用完成时收起时,提交正文输入或 steering 会立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。 轮次轨道与回到底部按钮位于正文裁剪层外。它们相对共享会话滚动容器做 sticky 定位;Chat 自己持有滚动容器时,则相对 Chat 外框做 absolute 定位。正文扣除水平内边距后的可用宽度不超过 900px 时,隐藏轮次轨道;判断依据不是浏览器视口宽度。 正文根节点使用 `overflow-x: visible; overflow-y: clip`:裁剪纵向溢出,但不创建滚动容器。因此,在没有更近的滚动祖先时,Markdown 代码块的 sticky 头栏和展开的压缩摘要头栏仍以实际会话滚动容器为参照。限高过程组和终端区域保留各自的滚动容器。 -外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 +外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 + +下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 + +切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。 + +折叠协调状态由各自的 viewport 持有,空间预留和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点分发,为本地回显提供 Factory renderer,并共享既有 Chat store 与 Session 节点来源。图片入口 Factory 拥有可替换的图片 Slot。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。 | 外层跟随 | 已展开小组跟随 | 回到底部按钮 | 新增内容 | |---|---|---|---| @@ -146,7 +166,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 `useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转,并向视口请求位置保持。阅读手势释放分页锚点,composer 内的点击、打字和非滚动按键则保留锚点;分页仍在加载时,`scrollend` 会记录读者的新位置。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。 -活跃轮次高亮采用近似定位:`readVisibleTurn` 对正文容器的直属 Node/Group 布局盒二分,落在间隙时保留前方候选。它不做文档命中测试,也不查询 Group 成员或全部 Turn 标记。空 Seat 保留零高度的流内布局盒,使外层位置有序且不增加间距。这种定位不改变语义位置捕获或分页补偿。 +活跃轮次高亮采用近似定位:`readVisibleTurn` 对 flow 插槽的直属 Node/Group 布局盒二分(独立渲染时使用正文容器的直属布局盒),落在间隙时保留前方候选。它不做文档命中测试,也不查询 Group 成员或全部 Turn 标记。空 Seat 保留零高度的流内布局盒,使外层位置有序且不增加间距。这种定位不改变语义位置捕获或分页补偿。
diff --git a/packages/client/ui-chat/locale/en.json b/packages/client/ui-chat/locale/en.json new file mode 100644 index 0000000000..a07944e34f --- /dev/null +++ b/packages/client/ui-chat/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-chat" + } +} diff --git a/packages/client/ui-chat/locale/zh.json b/packages/client/ui-chat/locale/zh.json new file mode 100644 index 0000000000..bece9e8048 --- /dev/null +++ b/packages/client/ui-chat/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "聊天内容展示", + "description": "渲染聊天消息、工具内容和相关详情。" + } +} diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 432346bf9d..1eb2d42930 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -87,9 +88,7 @@ "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@tanstack/react-virtual": "^3.14.10", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", + "@tanstack/react-virtual": "3.14.10", "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-settings": "workspace:*", @@ -99,6 +98,7 @@ "@deepseek-ai/schemastery": "workspace:~" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index c4c6ce3b95..8e76bb29ed 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -8,6 +8,18 @@ export const CHAT_SETTINGS_NAMESPACE = 'ui-chat' /** Field carrying the work-details presentation mode. */ export const TRANSCRIPT_VIEW_FIELD = 'transcriptView' +/** Field carrying the independent work-details collapse timing. */ +export const COLLAPSE_TIMING_FIELD = 'collapseTiming' + +/** Available points when a completed Turn returns to its historical presentation. */ +export const COLLAPSE_TIMINGS = ['completion', 'next-input'] as const + +/** When a completed Turn returns to its historical presentation. */ +export type CollapseTiming = typeof COLLAPSE_TIMINGS[number] + +/** Preserve completion-time folding when no timing preference is saved. */ +export const DEFAULT_COLLAPSE_TIMING: CollapseTiming = 'completion' + /** Work-details presentation modes a user can choose. */ export const TRANSCRIPT_VIEW_MODES = ['compact', 'standard', 'detailed', 'verbose'] as const @@ -50,6 +62,8 @@ export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar' export interface ChatSettings { /** Work-details preference; absence uses the client default, and legacy saved values remain accepted. */ transcriptView?: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE | typeof LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE | null + /** Independent collapse timing; absence preserves completion-time folding. */ + collapseTiming?: CollapseTiming /** Detail level for composer statistics and completed-Turn usage. */ performanceUsage: PerformanceUsageMode /** Default destination for Chat HTTP(S) links. */ @@ -58,6 +72,7 @@ export interface ChatSettings { /** Durable Chat schema; also the wire envelope the browser scope validates against. */ export const ChatSettingsFields = { + [COLLAPSE_TIMING_FIELD]: z.union([...COLLAPSE_TIMINGS]).default(DEFAULT_COLLAPSE_TIMING), linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING), performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE), // Missing and unrecognized modes defer to the client's default. diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index e83c851c3b..8aa63aa1f7 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -22,13 +22,16 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { - ChatNodeInjected, ChatScrollPosition, ChatViewInjected, QuotaNoticeInjected, QuotaNoticeState, TurnTailOwnerProps, + ChatFlowDataInjected, ChatFlowInjected, ChatNodeInjected, ChatScrollPosition, ChatViewInjected, + QuotaNoticeInjected, QuotaNoticeState, TurnTailOwnerProps, } from './contract/slots.ts' import type { ChatSnapshot } from './contract/snapshot.ts' import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts' import { ApprovalCommand } from './chat/ApprovalCommand.tsx' import { ChatView } from './chat/ChatView.tsx' +import { ChatFlow } from './chat/ChatFlow.tsx' import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' +import { UserContent } from './chat/UserContent.tsx' import { ActivityPill, UsagePill } from './chat/StatsPills.tsx' import { registerConversationNodes } from './conversation-nodes/register.ts' import { QuotaNoticeHost } from './chat/QuotaNoticeHost.tsx' @@ -43,13 +46,28 @@ import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './setting import { PerformanceUsagePolicy } from './performance-usage.ts' import { useTurnDataValue } from './chat/use-turn-data.ts' import { bindDisclosure } from './chat/use-disclosure.ts' +import { useFlowHidden, useMotionHidden } from './chat/flow-motion.ts' -const CHAT_NODE_INJECT: ChatNodeInjected = { +/** Bind group and header visibility to the viewport of one flow render. */ +export const CHAT_FLOW_INJECT: ChatFlowInjected = { + hooks: { + groupAction: (_standard, { motion }) => function useGroupAction(hidden, reveal) { + return useFlowHidden(hidden, reveal, motion) + }, + groupHeaderAction: (_standard, { motion }) => function useGroupHeaderAction(ref, hidden) { + useMotionHidden(ref, hidden, motion) + }, + }, +} + +/** Bind node-local sources and forward the flow's existing visibility hook. */ +export const CHAT_NODE_INJECT: ChatNodeInjected = { hooks: { turnData: (_standard, { turnData }) => function useTurnData(key) { return useTurnDataValue(turnData, key) }, disclosure: (_standard, { disclosureReset }) => bindDisclosure(disclosureReset), + groupAction: (_standard, { useGroupAction }) => useGroupAction, }, } @@ -147,11 +165,15 @@ export function apply(ctx: Context): void { }, LinkOpeningRow)) }) const transcriptView = new TranscriptViewPolicy(chatSettings, 'dshDesktop' in globalThis ? 'standard' : DEFAULT_TRANSCRIPT_VIEW_MODE) - const presentation = derivePresentationPolicy(transcriptView.mode) + const presentation = derivePresentationPolicy(transcriptView.mode, transcriptView.collapseTiming) const performancePolicy = new PerformanceUsagePolicy(chatSettings) ctx.effect(() => () => { transcriptView.dispose(); performancePolicy.dispose() }) const performanceUsage = performancePolicy.mode registerChatNodeRenderers(ctx, performanceUsage, presentation) + ctx.slots.registerFactory({ + name: 'conversation.chat.user.content', scope: 'session', locale: NS, + children: { 'conversation.chat.user.images': { kind: 'single', scope: 'session' } }, + }, UserContent) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', @@ -170,11 +192,38 @@ export function apply(ctx: Context): void { order: 12, locale: NS, inject: (): TranscriptViewRowInjected => ({ - hooks: { transcriptView: transcriptView.mode }, + hooks: { transcriptView: transcriptView.mode, collapseTiming: transcriptView.collapseTiming }, setTranscriptView: (mode) => { transcriptView.setMode(mode) }, + setCollapseTiming: (timing) => { transcriptView.setCollapseTiming(timing) }, }), }, TranscriptViewRow)) + const nodeSources = (binding: SessionBinding): ChatFlowDataInjected => { + const chat = chatSource(binding) + const conversation = ctx.uiConversation.binding(binding) + return { + hooks: { presentation }, + keyedHooks: { + chatNode: key => chat.getSnapshot().nodes.source(key), + chatNodeBottom: key => chat.getSnapshot().nodes.bottomSource(key), + chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key), + chatGroup: key => conversation.snapshot.getSnapshot().views.grouped('chat')?.groupSource(key as GroupKey), + }, + } + } + + ctx.slots.inject('conversation.chat.flow', () => ctx.slots.register({ + name: 'conversation.chat.flow', locale: NS, store: chatStore, + children: { + 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, + }, + inject: (sessionId: SessionId): ChatFlowDataInjected => { + const binding = ctx.sessions.binding(sessionId) + if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) + return nodeSources(binding) + }, + }, ChatFlow)) + ctx.slots.inject('conversation.view', () => { const disposeView = ctx.slots.register({ name: 'conversation.view', @@ -183,8 +232,7 @@ export function apply(ctx: Context): void { label: () => t('view.chat'), locale: NS, children: { - 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, - 'conversation.message.images': { kind: 'single', scope: 'session' }, + 'conversation.chat.flow': { kind: 'single', scope: 'session', inject: CHAT_FLOW_INJECT }, }, store: chatStore, inject: (sessionId: SessionId): ChatViewInjected => { @@ -192,14 +240,8 @@ export function apply(ctx: Context): void { if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) const session = binding.session const chat = chatSource(binding) - const conversation = ctx.uiConversation.binding(binding) return { - hooks: { presentation }, - keyedHooks: { - chatNode: key => chat.getSnapshot().nodes.source(key), - chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key), - chatGroup: key => conversation.snapshot.getSnapshot().views.grouped('chat')?.groupSource(key as GroupKey), - }, + ...nodeSources(binding), fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId), // Files open in the right Sidebar, not in a desktop application: the // content stays in the product, beside the conversation that produced @@ -220,11 +262,6 @@ export function apply(ctx: Context): void { else ctx.sidebarRight.openResource(url, { params: { line: options.line } }) await Promise.resolve() }, - openSkill: (name) => { - const scope = ctx.sessions.scope(sessionId) - if (scope === undefined) return - ctx.get('inputTriggers')?.sessionOf(scope).openReference('skill', { ref: `/${name}` }) - }, openExternalLink: (url) => { if (linkOpening.getSnapshot() === 'sidebar' && ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { ctx.sidebarRight.openTab('browser', { params: { url } }) diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 476565ecbb..22ae5bac70 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -4,11 +4,10 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatNodeOwnerProps, ChatViewSlotProps, UseDisclosure, UsePresentation } from '../contract/slots.ts' +import type { ChatNodeOwnerProps, ChatViewSlotProps, UseDisclosure, UsePresentation, UseGroupAction } from '../contract/slots.ts' import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' -import { useSearchableHidden } from './searchable-hidden.ts' import css from './AssistantMarkdown.module.css' /** @@ -28,7 +27,9 @@ export function localPathMediaUrl(base: string, value: string): string | undefin return fileMediaUrl(base, path) } -export interface AssistantMarkdownProps { +export interface AssistantMarkdownProps extends + Pick, 'renderSlot'>, + Pick { /** Render only the requested business portion, preserving original block indexes. */ groupPart?: string | undefined /** Stable Hook forwarded to each independently expandable reasoning block. */ @@ -37,12 +38,10 @@ export interface AssistantMarkdownProps { streaming: boolean /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined - /** Render consecutive image blocks through the attachment slot. */ - renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] - /** Optional expanded reasoning extension; absent for standalone renderers. */ - renderReasoningBody?: PropsRenderSlots<'conversation.chat.reasoning-body'>['renderSlotChain'] | undefined /** Hide reasoning that belongs to the Turn-level process disclosure. */ reasoningHidden?: boolean | undefined + /** Process hiding bound by the node slot to its current viewport. */ + useGroupAction: UseGroupAction /** Live display policy for reasoning summaries. */ usePresentation: UsePresentation /** Reveal the disclosure that hides this reasoning. */ @@ -53,10 +52,10 @@ export interface AssistantMarkdownProps { t: ChatViewSlotProps['t'] } -/** Reasoning block as the Think variant summary row (figma 39:28304). */ +/** Renders non-blank reasoning blocks as independent disclosures alongside the response. */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, renderMessageImages, groupPart, useDisclosure, - reasoningHidden = false, usePresentation, revealProcess, mentions, renderReasoningBody, t, + blocks, streaming, interrupted, loadImage, groupPart, useDisclosure, + reasoningHidden = false, usePresentation, revealProcess, mentions, renderSlot, useGroupAction, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -93,14 +92,16 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ ) break case 'reasoning': + if (block.text.trim() === '') break rendered.push( , ) break @@ -120,9 +121,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ } rendered.push( - {renderMessageImages({ - images: group.map(({ attachment }) => ({ attachment })), - align: 'start', + {renderSlot('conversation.chat.message.images', { + images: group.map(({ attachment }) => ({ attachment })), loadImage, align: 'start', })} , ) @@ -154,12 +154,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ ) }) -function ProcessReasoning({ hidden, reveal, children }: { +function ProcessReasoning({ hidden, reveal, useGroupAction, children }: { hidden: boolean reveal?: (() => void) | undefined + useGroupAction: UseGroupAction children: ReactNode }) { - const ref = useSearchableHidden(hidden, reveal ?? NOOP) + const ref = useGroupAction(hidden, reveal ?? NOOP) return
{children}
} diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index e934c4e708..5f61fe7007 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -4,12 +4,12 @@ import type { ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from import { AssistantMarkdown } from './AssistantMarkdown.tsx' type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace - & PropsRenderSlots<'conversation.chat.reasoning-body'> + & PropsRenderSlots<'conversation.chat.reasoning.body' | 'conversation.chat.message.images'> /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, groupPart, useDisclosure, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, - usePresentation, renderSlotChain, t, + node, groupPart, useDisclosure, useGroupAction, useTurnData, turnProcess, openFile, loadImage, fileMentions, + usePresentation, renderSlot, t, }: AssistantNodeViewProps) { const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' @@ -38,10 +38,11 @@ export const AssistantNodeView = memo(function AssistantNodeView({ useDisclosure={useDisclosure} streaming={data.status === 'running'} interrupted={data.status === 'interrupted'} - renderMessageImages={renderMessageImages} + loadImage={loadImage} reasoningHidden={reasoningHidden} + useGroupAction={useGroupAction} usePresentation={usePresentation} - renderReasoningBody={renderSlotChain} + renderSlot={renderSlot} revealProcess={revealProcess} mentions={mentions} t={t} diff --git a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx new file mode 100644 index 0000000000..c54e8a108f --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx @@ -0,0 +1,61 @@ +/** Ordered Chat rows, local echoes, and running status share the flow slot's parent. */ +import { memo } from 'react' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { ChatFlowSlotProps } from '../contract/slots.ts' +import { ChatNodeSeat } from './ChatNodeSeat.tsx' +import { ChatGroupSeat } from './ChatGroupSeat.tsx' +import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' +import { chatRenderKey } from './render-entry.ts' +import { RunningStatus } from './RunningStatus.tsx' + +/** Render the flow without adding a DOM parent or changing keyed row positions. */ +export const ChatFlow = memo(function ChatFlow({ + entries, pendingInputs, lastInputTurn, deferCollapse, + useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, + useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction, + cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, +}: ChatFlowSlotProps) { + const nodeStore = useChat(snapshot => snapshot.nodes) + const running = useSession(snapshot => snapshot.running) + const latestTurnAnchor = useChat(snapshot => snapshot.navigation.items().at(-1)?.anchorKey) + const runningStartTime = useChatNode(latestTurnAnchor ?? '', (node) => { + const location = node?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.status === 'open' ? location.turn.start?.time : undefined + : undefined + }) + const seatProps = { + nodeStore, useChatNode, useChatNodeBottom, useChatNodeProcess, usePresentation, + useStore, actions, renderSlot, t, useGroupAction, deferCollapse, + cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, + } + const rows = entries.map((entry) => { + switch (entry.kind) { + case 'node': + return + case 'group': + return + default: + return assertNever(entry) + } + }) + const pendingRows = pendingInputs.map(item => 'requestId' in item ? ( + + ) : ( + + )) + const tail = entries.at(-1) + const node = tail?.kind === 'node' ? nodeStore.get(tail.key) : undefined + // An empty opening control follows one local transcript echo, never steering. + // All rows share this keyed list so inserting the control keeps the echo mounted. + if (node?.kind === 'turn-process' && node.location.kind === 'turn' + && node.location.turn.status === 'open' && node.location.turn.turn !== lastInputTurn) { + const index = pendingInputs.findIndex(item => 'requestId' in item && item.placement === 'transcript') + if (index !== -1) rows.splice(rows.length - 1, 0, ...pendingRows.splice(index, 1)) + } + return [...rows, ...pendingRows, ...running ? [] : []] +}) diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css index f40328906c..c7797443d9 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css @@ -137,3 +137,14 @@ overflow: visible; scrollbar-gutter: auto; } + +/* Member spacing changes share the closing body's timing. */ +:global([data-chat-motion]) .content > :not([data-chat-motion]) { + transition: margin-top 160ms cubic-bezier(0.4, 0, 0.2, 1); +} + +@media (prefers-reduced-motion: reduce) { + :global([data-chat-motion]) .content > :not([data-chat-motion]) { + transition: none; + } +} diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index 443cb0407a..cdf34ce2ef 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -9,12 +9,12 @@ import { import type { GroupKey, NodeReference } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ProcessActivity } from '../contract/process-groups.ts' import type { ChatStoreState } from '../contract/store.ts' -import type { ChatViewSlotProps } from '../contract/slots.ts' -import { storedTurnProcessEntry } from '../stores.ts' +import type { ChatFlowSlotProps } from '../contract/slots.ts' +import { turnProcessOpen } from '../stores.ts' import { ChatNodeSeat } from './ChatNodeSeat.tsx' +import { hiddenOrCollapsing } from './flow-motion.ts' import { chatRenderKey } from './render-entry.ts' import { processTitle } from './step-process.ts' -import { useSearchableHidden } from './searchable-hidden.ts' import { useDisclosure } from './use-disclosure.ts' import { useProcessScroll } from './use-process-scroll.ts' import css from './ChatGroupSeat.module.css' @@ -22,7 +22,8 @@ import css from './ChatGroupSeat.module.css' type SeatProps = Omit, 'nodeKey' | 'groupPart'> type ChatGroupSeatProps = SeatProps & { readonly groupKey: GroupKey - readonly useChatGroup: ChatViewSlotProps['useChatGroup'] + readonly useChatGroup: ChatFlowSlotProps['useChatGroup'] + readonly useGroupHeaderAction: ChatFlowSlotProps['useGroupHeaderAction'] } const PROCESS_TITLE_MINIMUM_MS = 150 @@ -90,9 +91,9 @@ const GroupMembers = memo(function GroupMembers({ members, ...props }: SeatProps const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatGroup, usePresentation, t, open, bodyId, toggle }: { readonly groupKey: GroupKey - readonly useChatGroup: ChatViewSlotProps['useChatGroup'] - readonly usePresentation: ChatViewSlotProps['usePresentation'] - readonly t: ChatViewSlotProps['t'] + readonly useChatGroup: ChatFlowSlotProps['useChatGroup'] + readonly usePresentation: ChatFlowSlotProps['usePresentation'] + readonly t: ChatFlowSlotProps['t'] readonly open: boolean readonly bodyId: string readonly toggle: () => void @@ -128,38 +129,46 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG }) /** Render a process group with local disclosure and the existing outer-Turn visibility. */ -export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGroup, ...props }: ChatGroupSeatProps) { +export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGroup, useGroupHeaderAction, ...props }: ChatGroupSeatProps) { const members = useChatGroup(groupKey, group => group?.members) const turn = useChatGroup(groupKey, group => group?.data.turn) const closed = useChatGroup(groupKey, group => group?.data.closed) const foldCompleted = props.usePresentation(policy => policy.foldCompletedTurns) const { expanded: open, setExpanded: setOpen } = useDisclosure() const firstKey = members?.[0]?.key ?? '' + const bottom = props.useChatNodeBottom(firstKey) + const deferCollapse = bottom === true && props.deferCollapse const presentation = props.useChatNodeProcess(firstKey) const turnLocation = props.useChatNode(firstKey, (node) => { const location = node?.location return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined }) + // Detailed keeps the latest completed Turn flat, like a running one, until the next input. const grouped = props.usePresentation(policy => policy.stepGrouping === 'collapsed' - || (policy.stepGrouping === 'history' && turnLocation?.status !== 'open')) + || (policy.stepGrouping === 'history' && turnLocation?.status !== 'open' && !deferCollapse)) const reason = turnLocation?.end?.data.reason.kind const alwaysOpen = presentation?.turnClosed === false || presentation?.hasInterleavedInput === true || reason === 'aborted' || reason === 'error' const spec = presentation?.spec - const selectStored = useCallback((state: Readonly) => turn === undefined - ? undefined : storedTurnProcessEntry(state, turn), [turn]) - const stored = props.useStore(selectStored) + const selectOpen = useCallback((state: Readonly) => spec !== undefined + && turnProcessOpen(state, spec, deferCollapse), [spec, deferCollapse]) + const storedOpen = props.useStore(selectOpen) const outerHidden = foldCompleted && presentation?.turnClosed === true && spec !== undefined - && !alwaysOpen && stored?.answerStep !== (spec.answerStep ?? 0) + && !alwaysOpen && !storedOpen const revealOuter = useCallback(() => { if (spec !== undefined && !alwaysOpen) props.actions.setTurnProcessOpen(spec.turn, spec.answerStep ?? 0, true) }, [props.actions, spec, alwaysOpen]) - const rootRef = useSearchableHidden(outerHidden, revealOuter) + const rootRef = props.useGroupAction(outerHidden, revealOuter) + // The header appears when a flat latest Turn becomes historical; it grows on the fold clock while the + // body closes. A group whose whole Turn is folding never shows one in the frame it starts closing. + const headerRef = useRef(null) + const deferCompletedTurns = props.usePresentation(policy => policy.collapseTiming === 'next-input') + useGroupHeaderAction(headerRef, !grouped || (deferCompletedTurns && outerHidden)) useEffect(() => { - if (outerHidden && rootRef.current?.hasAttribute('hidden')) setOpen(false) + if (outerHidden && rootRef.current !== null && hiddenOrCollapsing(rootRef.current)) setOpen(false) }, [outerHidden, rootRef, setOpen]) const reveal = useCallback(() => { setOpen(true) }, [setOpen]) - const bodyRef = useSearchableHidden(grouped && !open, reveal) + const bodyRef = props.useGroupAction(grouped && !open, reveal) const contentRef = useRef(null) const bodyId = useId() const { edges, events, initialize } = useProcessScroll(bodyRef, contentRef, open, grouped) @@ -168,14 +177,17 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou setOpen(!open) }, [closed, initialize, open, setOpen]) if (members === undefined) return null - const classes = [css.body, !grouped ? css.expandedBody : '', - grouped && edges.canScrollUp ? css.fadeTop : '', grouped && edges.canScrollDown ? css.fadeBottom : ''] + // The height cap applies only to a group the reader opened; a body that is closing keeps its flat + // height so the transition starts from what is on screen instead of snapping to the cap first. + const capped = grouped && (!deferCompletedTurns || open) + const classes = [css.body, !capped ? css.expandedBody : '', + capped && edges.canScrollUp ? css.fadeTop : '', capped && edges.canScrollDown ? css.fadeBottom : ''] return (
-